Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ The models included with {% data variables.product.prodname_copilot_short %} pla

Model multipliers and costs are subject to change.

> [!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.
> [!NOTE] Users on legacy annual {% data variables.product.prodname_copilot_short %} plans will not receive access to new models and features.

## Model multipliers

Expand All @@ -36,6 +36,8 @@ The following table shows the model multipliers per supported model.
> * {% data variables.copilot.copilot_claude_sonnet_46 %}
> * {% data variables.copilot.copilot_gpt_54_mini %}
> * The multiplier for {% data variables.copilot.copilot_mai_code_1_flash %} is a promotional rate.
>
> 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.

| Model | Multiplier |
| --- | ---: |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,20 @@ The `dependabot.yml` file doesn't control the versioning tags that you can use,
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
| git submodule | `gitsubmodule` | None—pins to commit SHAs or git tags (no versioning scheme) | `my-lib@abc1234`, `shared-utils@v1.2.0` |
| 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` |

#### Ecosystem-specific versioning details

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

* **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`).
* **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.
* **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.
* **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.
* **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.
* **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.
* **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.
6 changes: 6 additions & 0 deletions src/search/lib/ai-search-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,9 @@
// payloads are almost always pasted docs pages or unrelated content
// and either time out or return no-answer. See github/cse-copilot#1214.
export const MAX_QUERY_LENGTH = 500

// cse-copilot returns HTTP 400 with this code in `detail.code` when Azure's
// Responsible AI input content filter rejects a query. These are expected,
// user-triggered rejections, so they are tracked separately and kept out of
// the AI search error-rate monitor. See github/cse-copilot#1214.
export const RAI_CONTENT_FILTER_CODE = 'RAI_INPUT_CONTENT_POLICY_BREACH_ERROR'
32 changes: 29 additions & 3 deletions src/search/lib/ai-search-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { getHmacWithEpoch } from '@/search/lib/helpers/get-cse-copilot-auth'
import { getCSECopilotSource } from '@/search/lib/helpers/cse-copilot-docs-versions'
import type { ExtendedRequest } from '@/types'
import { handleExternalSearchAnalytics } from '@/search/lib/helpers/external-search-analytics'
import { MAX_QUERY_LENGTH } from '@/search/lib/ai-search-constants'
import { MAX_QUERY_LENGTH, RAI_CONTENT_FILTER_CODE } from '@/search/lib/ai-search-constants'

const logger = createLogger(import.meta.url)

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

type ContentFilterCandidate = {
status: number
headers: { get: (name: string) => string | null }
json: () => Promise<unknown>
}

const isContentFilterRejection = async (response: ContentFilterCandidate): Promise<boolean> => {
if (response.status !== 400) {
return false
}
if (!response.headers.get('content-type')?.includes('application/json')) {
return false
}
try {
const body = (await response.json()) as { detail?: { code?: string } }
return body?.detail?.code === RAI_CONTENT_FILTER_CODE
} catch {
return false
}
}

export const aiSearchProxy = async (req: ExtendedRequest, res: Response) => {
const { query, version } = req.body ?? {}

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

if (!response.ok) {
const errorMessage = `Upstream server responded with status code ${response.status}`
logger.error(errorMessage, { statusCode: response.status })
statsd.increment('ai-search.stream_response_error', 1, diagnosticTags)
if (await isContentFilterRejection(response)) {
logger.info(errorMessage, { statusCode: response.status })
statsd.increment('ai-search.content_filtered', 1, diagnosticTags)
} else {
logger.error(errorMessage, { statusCode: response.status })
statsd.increment('ai-search.stream_response_error', 1, diagnosticTags)
}
res.status(response.status).json({
errors: [{ message: errorMessage }],
upstreamStatus: response.status,
Expand Down
148 changes: 148 additions & 0 deletions src/search/tests/ai-search-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { describe, test, expect, vi, beforeEach } from 'vitest'

import statsd from '@/observability/lib/statsd'
import { fetchStream } from '@/frame/lib/fetch-utils'
import { aiSearchProxy } from '@/search/lib/ai-search-proxy'
import { RAI_CONTENT_FILTER_CODE } from '@/search/lib/ai-search-constants'
import type { ExtendedRequest } from '@/types'

vi.mock('@/observability/lib/statsd', () => ({
default: { increment: vi.fn(), gauge: vi.fn() },
}))

vi.mock('@/frame/lib/fetch-utils', () => ({
fetchStream: vi.fn(),
}))

vi.mock('@/search/lib/helpers/get-cse-copilot-auth', () => ({
getHmacWithEpoch: () => 'test-auth',
}))

vi.mock('@/search/lib/helpers/cse-copilot-docs-versions', () => ({
getCSECopilotSource: () => 'docs',
}))

vi.mock('@/search/lib/helpers/external-search-analytics', () => ({
handleExternalSearchAnalytics: async () => null,
}))

const incrementedMetrics = () =>
(statsd.increment as ReturnType<typeof vi.fn>).mock.calls.map((call) => call[0])

function buildResponse() {
const res = {
statusCode: 0,
body: null as unknown,
status(code: number) {
this.statusCode = code
return this
},
json(payload: unknown) {
this.body = payload
return this
},
setHeader: vi.fn(),
flushHeaders: vi.fn(),
write: vi.fn(),
end: vi.fn(),
headersSent: false,
}
return res
}

function buildRequest(): ExtendedRequest {
return {
body: { query: 'a disallowed query', version: 'dotcom' },
language: 'en',
} as unknown as ExtendedRequest
}

function mockUpstream(
status: number,
jsonBody: unknown | (() => never),
contentType = 'application/json',
) {
const json = vi.fn(async () => {
if (typeof jsonBody === 'function') {
return (jsonBody as () => never)()
}
return jsonBody
})
;(fetchStream as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: status < 400,
status,
headers: {
get: (name: string) => (name.toLowerCase() === 'content-type' ? contentType : null),
},
json,
})
return json
}

describe('aiSearchProxy upstream 400 handling', () => {
beforeEach(() => {
vi.clearAllMocks()
})

test('RAI content filter 400 increments content_filtered, not stream_response_error', async () => {
mockUpstream(400, {
code: 400,
message: 'Responsible AI input content policy breach',
detail: { code: RAI_CONTENT_FILTER_CODE },
})
const res = buildResponse()

await aiSearchProxy(buildRequest(), res as never)

const metrics = incrementedMetrics()
expect(metrics).toContain('ai-search.content_filtered')
expect(metrics).not.toContain('ai-search.stream_response_error')
expect(metrics).toContain('ai-search.call')
expect(res.statusCode).toBe(400)
})

test('non-RAI 400 increments stream_response_error', async () => {
mockUpstream(400, { code: 400, message: 'some other bad request' })
const res = buildResponse()

await aiSearchProxy(buildRequest(), res as never)

const metrics = incrementedMetrics()
expect(metrics).toContain('ai-search.stream_response_error')
expect(metrics).not.toContain('ai-search.content_filtered')
expect(res.statusCode).toBe(400)
})

test('malformed 400 body increments stream_response_error', async () => {
mockUpstream(400, () => {
throw new Error('invalid json')
})
const res = buildResponse()

await aiSearchProxy(buildRequest(), res as never)

const metrics = incrementedMetrics()
expect(metrics).toContain('ai-search.stream_response_error')
expect(metrics).not.toContain('ai-search.content_filtered')
expect(res.statusCode).toBe(400)
})

test('non-JSON 400 body increments stream_response_error without parsing', async () => {
const json = mockUpstream(
400,
() => {
throw new Error('should not be parsed')
},
'text/html',
)
const res = buildResponse()

await aiSearchProxy(buildRequest(), res as never)

const metrics = incrementedMetrics()
expect(json).not.toHaveBeenCalled()
expect(metrics).toContain('ai-search.stream_response_error')
expect(metrics).not.toContain('ai-search.content_filtered')
expect(res.statusCode).toBe(400)
})
})
Loading