Skip to content

Commit ccba0a6

Browse files
authored
Merge pull request #44647 from github/repo-sync
Repo sync
2 parents 689e7f8 + 4a5568d commit ccba0a6

5 files changed

Lines changed: 195 additions & 4 deletions

File tree

content/copilot/reference/copilot-billing/request-based-billing-legacy/model-multipliers-for-annual-plans.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ The models included with {% data variables.product.prodname_copilot_short %} pla
2424

2525
Model multipliers and costs are subject to change.
2626

27-
> [!NOTE] If you use {% data variables.copilot.copilot_auto_model_selection_short %} in {% data variables.copilot.copilot_chat_short %}, {% data variables.copilot.copilot_cli_short %}, or {% data variables.copilot.copilot_cloud_agent %}, you qualify for a 10% discount. For example, if a model has a multiplier of 1x you'll be billed at 0.9x instead.
27+
> [!NOTE] Users on legacy annual {% data variables.product.prodname_copilot_short %} plans will not receive access to new models and features.
2828
2929
## Model multipliers
3030

@@ -36,6 +36,8 @@ The following table shows the model multipliers per supported model.
3636
> * {% data variables.copilot.copilot_claude_sonnet_46 %}
3737
> * {% data variables.copilot.copilot_gpt_54_mini %}
3838
> * The multiplier for {% data variables.copilot.copilot_mai_code_1_flash %} is a promotional rate.
39+
>
40+
> If you use {% data variables.copilot.copilot_auto_model_selection_short %} in {% data variables.copilot.copilot_chat_short %}, {% data variables.copilot.copilot_cli_short %}, or {% data variables.copilot.copilot_cloud_agent %}, you qualify for a 10% discount. For example, if a model has a multiplier of 1x you'll be billed at 0.9x instead.
3941
4042
| Model | Multiplier |
4143
| --- | ---: |

data/reusables/dependabot/dependabot-updates-supported-versioning-tags.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,20 @@ The `dependabot.yml` file doesn't control the versioning tags that you can use,
1919
| pipenv | `pip` | `a`, `b`, `rc`, `dev`, `post` | `requests@1.0.0a1`, `numpy@2.0.0b3`, `django@4.0rc1`, `black@1.0.0.dev5`, `pandas@2.0.5.post1` |
2020
| pip-compile | `pip` | `a`, `b`, `rc`, `dev`, `post` | `requests@1.0.0a1`, `numpy@2.0.0b3`, `django@4.0rc1`, `black@1.0.0.dev5`, `pandas@2.0.5.post1` |
2121
| poetry | `pip` | `a`, `b`, `rc`, `dev`, `post` | `requests@1.0.0a1`, `numpy@2.0.0b3`, `django@4.0rc1`, `black@1.0.0.dev5`, `pandas@2.0.5.post1` |
22+
| Gradle | `gradle` | `alpha`, `a`, `beta`, `b`, `milestone`, `m`, `rc`, `cr`, `snapshot`, `ga`, `final`, `release`, `sp` (case-insensitive) | `spring-boot-starter@3.0.0-RC1`, `kotlin-stdlib@2.0.0-beta`, `guava@33.0.0-SNAPSHOT`, `junit@5.10.0-M2`, `ktor@2.3.0-rc.1` |
23+
| Elm | `elm` | None—strict `MAJOR.MINOR.PATCH` only (no pre-release versions) | `elm/core@1.0.0`, `elm/html@2.3.1`, `elm/json@10.0.0` |
24+
| Docker | `docker` | `alpha`, `beta`, `rc`, `dev`, `preview`, `pre`, `nightly`, `snapshot`, `canary`, `unstable` (heuristic detection) | `nginx@1.25.0-rc1`, `node@20.0.0-alpha.1`, `redis@7.0.0-nightly`, `alpine@3.18.0-dev`, `ubuntu@22.04-preview` |
25+
| git submodule | `gitsubmodule` | None—pins to commit SHAs or git tags (no versioning scheme) | `my-lib@abc1234`, `shared-utils@v1.2.0` |
26+
| Go modules | `gomod` | `alpha`, `beta`, `rc` (SemVer prerelease after `-`) | `github.com/go-chi/chi@v5.0.0-rc1`, `google.golang.org/grpc@v1.60.0-beta.1`, `github.com/octo-org/octo-module@v0.17.0-alpha.1` |
2227

2328
#### Ecosystem-specific versioning details
2429

2530
The following details describe how {% data variables.product.prodname_dependabot %} interprets versioning for specific ecosystems.
2631

2732
* **Bundler:** Bundler does not use a fixed set of prerelease tags. Any version segment containing a letter is treated as a prerelease (for example, `.alpha`, `.beta1`, `.rc2`). Hyphens in version strings are normalized to `.pre.` internally (for example, `1.0.0-beta` becomes `1.0.0.pre.beta`).
2833
* **Cargo:** Follows SemVer 2.0.0 prerelease conventions. Anything after `-` is a prerelease identifier (dot-separated, `[0-9A-Za-z-]`). Build metadata (`+...`) is allowed but ignored for version precedence.
34+
* **Gradle:** Aliases are also recognized: `pr`/`pre`/`preview``rc`, `eap`/`ea``alpha`. Additional prerelease qualifiers include `dev`, `experimental`, and `unstable`. Qualifiers are ordered by precedence: `alpha`/`a` < `beta`/`b` < `milestone`/`m` < `rc`/`cr` < `snapshot` < (empty/`ga`/`final`/`release`) < `sp`. Free-form identifiers not in this list are treated as stable.
2935
* **pip/pipenv/pip-compile/poetry (PEP 440):** The table lists canonical prerelease and postrelease suffixes per PEP 440. Aliases are also recognized and normalized to their canonical forms (`alpha``a`, `beta``b`, `c`/`pre`/`preview``rc`, `rev`/`r``post`). Epoch versions (`N!...`) and local versions (`+local`) are supported; local version segments are used only to break ties when the public version is identical.
36+
* **Elm:** The Elm package registry enforces strict SemVer (`MAJOR.MINOR.PATCH` integers only) and does not allow publishing pre-release versions. Dependabot compares versions numerically.
37+
* **Go modules:** Follows SemVer with a mandatory `v` prefix. Pseudo-versions (`v0.0.0-YYYYMMDDHHMMSS-commithash`) are used for unreleased commits and are always treated as pre-release. The `+incompatible` suffix marks modules at major version 2+ without a `go.mod` file and does not affect version ordering.
38+
* **git submodule:** Dependabot tracks the latest commit on the configured branch. There is no version comparison—updates always move the pinned SHA forward. If the submodule tracks a tag, Dependabot follows the tag's commit.

src/search/lib/ai-search-constants.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,9 @@
22
// payloads are almost always pasted docs pages or unrelated content
33
// and either time out or return no-answer. See github/cse-copilot#1214.
44
export const MAX_QUERY_LENGTH = 500
5+
6+
// cse-copilot returns HTTP 400 with this code in `detail.code` when Azure's
7+
// Responsible AI input content filter rejects a query. These are expected,
8+
// user-triggered rejections, so they are tracked separately and kept out of
9+
// the AI search error-rate monitor. See github/cse-copilot#1214.
10+
export const RAI_CONTENT_FILTER_CODE = 'RAI_INPUT_CONTENT_POLICY_BREACH_ERROR'

src/search/lib/ai-search-proxy.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { getHmacWithEpoch } from '@/search/lib/helpers/get-cse-copilot-auth'
66
import { getCSECopilotSource } from '@/search/lib/helpers/cse-copilot-docs-versions'
77
import type { ExtendedRequest } from '@/types'
88
import { handleExternalSearchAnalytics } from '@/search/lib/helpers/external-search-analytics'
9-
import { MAX_QUERY_LENGTH } from '@/search/lib/ai-search-constants'
9+
import { MAX_QUERY_LENGTH, RAI_CONTENT_FILTER_CODE } from '@/search/lib/ai-search-constants'
1010

1111
const logger = createLogger(import.meta.url)
1212

@@ -15,6 +15,27 @@ const logger = createLogger(import.meta.url)
1515
// established, but the connect + first-byte must complete within this window.
1616
const AI_SEARCH_TIMEOUT_MS = 9_000
1717

18+
type ContentFilterCandidate = {
19+
status: number
20+
headers: { get: (name: string) => string | null }
21+
json: () => Promise<unknown>
22+
}
23+
24+
const isContentFilterRejection = async (response: ContentFilterCandidate): Promise<boolean> => {
25+
if (response.status !== 400) {
26+
return false
27+
}
28+
if (!response.headers.get('content-type')?.includes('application/json')) {
29+
return false
30+
}
31+
try {
32+
const body = (await response.json()) as { detail?: { code?: string } }
33+
return body?.detail?.code === RAI_CONTENT_FILTER_CODE
34+
} catch {
35+
return false
36+
}
37+
}
38+
1839
export const aiSearchProxy = async (req: ExtendedRequest, res: Response) => {
1940
const { query, version } = req.body ?? {}
2041

@@ -100,8 +121,13 @@ export const aiSearchProxy = async (req: ExtendedRequest, res: Response) => {
100121

101122
if (!response.ok) {
102123
const errorMessage = `Upstream server responded with status code ${response.status}`
103-
logger.error(errorMessage, { statusCode: response.status })
104-
statsd.increment('ai-search.stream_response_error', 1, diagnosticTags)
124+
if (await isContentFilterRejection(response)) {
125+
logger.info(errorMessage, { statusCode: response.status })
126+
statsd.increment('ai-search.content_filtered', 1, diagnosticTags)
127+
} else {
128+
logger.error(errorMessage, { statusCode: response.status })
129+
statsd.increment('ai-search.stream_response_error', 1, diagnosticTags)
130+
}
105131
res.status(response.status).json({
106132
errors: [{ message: errorMessage }],
107133
upstreamStatus: response.status,
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { describe, test, expect, vi, beforeEach } from 'vitest'
2+
3+
import statsd from '@/observability/lib/statsd'
4+
import { fetchStream } from '@/frame/lib/fetch-utils'
5+
import { aiSearchProxy } from '@/search/lib/ai-search-proxy'
6+
import { RAI_CONTENT_FILTER_CODE } from '@/search/lib/ai-search-constants'
7+
import type { ExtendedRequest } from '@/types'
8+
9+
vi.mock('@/observability/lib/statsd', () => ({
10+
default: { increment: vi.fn(), gauge: vi.fn() },
11+
}))
12+
13+
vi.mock('@/frame/lib/fetch-utils', () => ({
14+
fetchStream: vi.fn(),
15+
}))
16+
17+
vi.mock('@/search/lib/helpers/get-cse-copilot-auth', () => ({
18+
getHmacWithEpoch: () => 'test-auth',
19+
}))
20+
21+
vi.mock('@/search/lib/helpers/cse-copilot-docs-versions', () => ({
22+
getCSECopilotSource: () => 'docs',
23+
}))
24+
25+
vi.mock('@/search/lib/helpers/external-search-analytics', () => ({
26+
handleExternalSearchAnalytics: async () => null,
27+
}))
28+
29+
const incrementedMetrics = () =>
30+
(statsd.increment as ReturnType<typeof vi.fn>).mock.calls.map((call) => call[0])
31+
32+
function buildResponse() {
33+
const res = {
34+
statusCode: 0,
35+
body: null as unknown,
36+
status(code: number) {
37+
this.statusCode = code
38+
return this
39+
},
40+
json(payload: unknown) {
41+
this.body = payload
42+
return this
43+
},
44+
setHeader: vi.fn(),
45+
flushHeaders: vi.fn(),
46+
write: vi.fn(),
47+
end: vi.fn(),
48+
headersSent: false,
49+
}
50+
return res
51+
}
52+
53+
function buildRequest(): ExtendedRequest {
54+
return {
55+
body: { query: 'a disallowed query', version: 'dotcom' },
56+
language: 'en',
57+
} as unknown as ExtendedRequest
58+
}
59+
60+
function mockUpstream(
61+
status: number,
62+
jsonBody: unknown | (() => never),
63+
contentType = 'application/json',
64+
) {
65+
const json = vi.fn(async () => {
66+
if (typeof jsonBody === 'function') {
67+
return (jsonBody as () => never)()
68+
}
69+
return jsonBody
70+
})
71+
;(fetchStream as ReturnType<typeof vi.fn>).mockResolvedValue({
72+
ok: status < 400,
73+
status,
74+
headers: {
75+
get: (name: string) => (name.toLowerCase() === 'content-type' ? contentType : null),
76+
},
77+
json,
78+
})
79+
return json
80+
}
81+
82+
describe('aiSearchProxy upstream 400 handling', () => {
83+
beforeEach(() => {
84+
vi.clearAllMocks()
85+
})
86+
87+
test('RAI content filter 400 increments content_filtered, not stream_response_error', async () => {
88+
mockUpstream(400, {
89+
code: 400,
90+
message: 'Responsible AI input content policy breach',
91+
detail: { code: RAI_CONTENT_FILTER_CODE },
92+
})
93+
const res = buildResponse()
94+
95+
await aiSearchProxy(buildRequest(), res as never)
96+
97+
const metrics = incrementedMetrics()
98+
expect(metrics).toContain('ai-search.content_filtered')
99+
expect(metrics).not.toContain('ai-search.stream_response_error')
100+
expect(metrics).toContain('ai-search.call')
101+
expect(res.statusCode).toBe(400)
102+
})
103+
104+
test('non-RAI 400 increments stream_response_error', async () => {
105+
mockUpstream(400, { code: 400, message: 'some other bad request' })
106+
const res = buildResponse()
107+
108+
await aiSearchProxy(buildRequest(), res as never)
109+
110+
const metrics = incrementedMetrics()
111+
expect(metrics).toContain('ai-search.stream_response_error')
112+
expect(metrics).not.toContain('ai-search.content_filtered')
113+
expect(res.statusCode).toBe(400)
114+
})
115+
116+
test('malformed 400 body increments stream_response_error', async () => {
117+
mockUpstream(400, () => {
118+
throw new Error('invalid json')
119+
})
120+
const res = buildResponse()
121+
122+
await aiSearchProxy(buildRequest(), res as never)
123+
124+
const metrics = incrementedMetrics()
125+
expect(metrics).toContain('ai-search.stream_response_error')
126+
expect(metrics).not.toContain('ai-search.content_filtered')
127+
expect(res.statusCode).toBe(400)
128+
})
129+
130+
test('non-JSON 400 body increments stream_response_error without parsing', async () => {
131+
const json = mockUpstream(
132+
400,
133+
() => {
134+
throw new Error('should not be parsed')
135+
},
136+
'text/html',
137+
)
138+
const res = buildResponse()
139+
140+
await aiSearchProxy(buildRequest(), res as never)
141+
142+
const metrics = incrementedMetrics()
143+
expect(json).not.toHaveBeenCalled()
144+
expect(metrics).toContain('ai-search.stream_response_error')
145+
expect(metrics).not.toContain('ai-search.content_filtered')
146+
expect(res.statusCode).toBe(400)
147+
})
148+
})

0 commit comments

Comments
 (0)