diff --git a/docs-site/src/content/docs/guides/model-routing.md b/docs-site/src/content/docs/guides/model-routing.md index ac8e6ae62bb..74f08b67ec0 100644 --- a/docs-site/src/content/docs/guides/model-routing.md +++ b/docs-site/src/content/docs/guides/model-routing.md @@ -105,6 +105,15 @@ Routing and catalog visibility are separate controls: switching it on restores that selection. A remembered selection never applies a limit while disabled. Sending `{ "setAll": true }` without `value` enables all configured providers at the current global value and replaces their remembered selections. +- A GitHub Copilot model that supports the upstream long-context tier can opt in with + `providers.github-copilot.modelContextTiers. = "long_context"` or + `ocx provider edit github-copilot --model-context-tier =long_context`. + The tier sends `contextTier: "long_context"` on Copilot Chat and Responses requests. + It raises the advertised window only when that exact model has a known per-model window + in `modelContextWindows` (for example, `1000000`); an unknown model keeps its live window. + An explicit per-model window wins, then the existing provider cap applies. + The `"default"` tier retains normal live context metadata. + The dashboard control is planned separately. ```json { diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index df6bb53a659..81fba9813c8 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -16,7 +16,7 @@ both `--adapter` and `--base-url`. | --- | --- | --- | | `list` | `--json`, `--jsonl` | List configured providers and the remaining registry entries; `--jsonl` emits one configured provider object per line. | | `add ` | `--adapter `, `--base-url `, `--api-key `, `--default-model `, `--set-default`, `--force`, `--json`, `--sync` | Add a registry/custom provider. `--force` overwrites; `--sync` refreshes a running proxy in human-output mode. | -| `edit ` | provider field flags, `--headers `, `--json` | Edit validated live provider fields without replacing key pools. `--headers` merges custom request headers; pass `{}` or `-` to clear them. | +| `edit ` | provider field flags, `--headers `, `--model-context-tier `, `--json` | Edit validated live provider fields without replacing key pools. Repeat `--model-context-tier` for multiple Copilot models. `--headers` merges custom request headers; pass `{}` or `-` to clear them. | | `test ` | `--json` | Probe the real upstream model endpoint. | | `show ` | `--json` | Show config with API keys masked. | | `remove ` | `--json` | Remove a non-default provider; the last provider cannot be removed. | @@ -34,6 +34,7 @@ ocx provider test ark ocx provider add anthropic --api-key sk-ant-... --set-default --sync ocx provider add local-dev --adapter openai-chat --base-url http://localhost:11434/v1 ocx provider show anthropic --json +ocx provider edit github-copilot --model-context-tier gpt-5.6-luna=long_context ocx models --provider anthropic --json ocx models live --provider ark --json ``` diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index bfb7829fdad..745e3536ad0 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -225,6 +225,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `modelDisplayNames?` | `Record` | Durable labels used only for display, keyed by this provider's exact upstream model id. Labels win over provider catalog metadata, survive discovery refreshes and provider edits, and never change authentication, adapter behavior, routing, billing, upstream request construction, the routed `provider/model` selector, or the upstream wire model. Keys are exact and case sensitive. Unknown model ids are kept so a temporarily missing model receives its label when it returns. The map accepts at most 2,000 entries, matching the discovery limit. | | `contextWindow?` | `number` | Provider-wide context fallback when upstream metadata is absent; otherwise a cap that retains smaller live metadata. The Models dashboard exposes this separately from `providerContextCaps`. | | `modelContextWindows?` | `Record` | Per-model context fallbacks/caps. These override `contextWindow`: an unknown window uses the configured value, while smaller live metadata remains authoritative. | +| `modelContextTiers?` | `Record` | Per-model upstream tier for `github-copilot`. `long_context` sends `contextTier: "long_context"`. Catalog metadata grows only with an exact per-model `modelContextWindows` value that documents the supported window; that value wins before the provider cap. An unknown model keeps its observed window. Unconfigured passthrough requests retain a caller-supplied `contextTier` field on any provider. | | `modelInputModalities?` | `Record` | Per-model input hints such as `["text"]` or `["text", "image"]`. | | `modelMaxInputTokens?` | `Record` | Positive per-model max input limits used for catalog auto-compaction hints. | | `modelAutoCompactTokenLimits?` | `Record` | Positive safe-integer per-model soft auto-compaction budgets. Values can only lower the effective 90%-of-context/max-input envelope and are omitted when no authoritative context window is known. For canonical `openai`, keys must be exact supported native model IDs without provider or account-selector prefixes. Provider PATCH merges entries; set a key to `null` to delete it or the whole field to `null` to clear the map. These `null` tombstones are PATCH-only. | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 5f461d6a10f..e200fcc4d51 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -830,6 +830,7 @@ "gemini-web-search.test.ts": "adapters/google", "generic-oauth-failover.test.ts": "oauth", "github-copilot-account-origin.test.ts": "providers/github-copilot", + "github-copilot-context-tier.test.ts": "providers/github-copilot", "github-copilot-oauth.test.ts": "providers/github-copilot", "github-copilot-sse-rewrite.test.ts": "providers/github-copilot", "github-copilot-stream-contract.test.ts": "providers/github-copilot", diff --git a/src/adapters/base.ts b/src/adapters/base.ts index 95252627f24..431e3fb1309 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -8,6 +8,8 @@ import type { ProviderRequestSlot } from "../providers/request-pacing"; /** Metadata about the caller's incoming request, for auth-forwarding adapters. */ export interface IncomingMeta { headers: Headers; + /** Canonical routed provider identity for provider-specific wire fields. */ + providerName?: string; translatorBudget: TranslatorBudget; abortSignal?: AbortSignal; /** Lease acquired before the response is committed; the physical send transfers it to its body. */ diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 6835be4354c..b40702f7c7d 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -13,6 +13,7 @@ import { isDebugEnabled } from "../lib/debug-settings"; import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing"; import { resolveVercelGatewayRouting, vercelGatewayProviderPayload } from "../providers/vercel-gateway-routing"; import { fastPolicyForModel } from "../providers/service-tier"; +import { applyGithubCopilotContextTier } from "../providers/github-copilot-context"; import { createAdapterTierMetadata, decideTier, type AdapterTierMetadata } from "../providers/fastwire"; import { isTranslatorBudgetExceededError, @@ -229,7 +230,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd } if (parsed.stream) body.stream_options = { include_usage: true }; - const bodyJson = JSON.stringify(body); + const bodyJson = JSON.stringify(applyGithubCopilotContextTier(body, provider, parsed.modelId, incoming?.providerName)); const actualServiceTier = typeof body.service_tier === "string" ? body.service_tier : null; const tierLog = createAdapterTierMetadata( parsed.options.tierObservation, diff --git a/src/adapters/openai-chat/passthrough.ts b/src/adapters/openai-chat/passthrough.ts index ed01bf13a6c..d206be61b6a 100644 --- a/src/adapters/openai-chat/passthrough.ts +++ b/src/adapters/openai-chat/passthrough.ts @@ -6,6 +6,7 @@ import { applyExplicitChatDeveloperRole } from "./developer-role"; import { openRouterProviderPayload, resolveOpenRouterRouting } from "../../providers/openrouter-routing"; import { resolveVercelGatewayRouting, vercelGatewayProviderPayload } from "../../providers/vercel-gateway-routing"; import { fastPolicyForModel } from "../../providers/service-tier"; +import { applyGithubCopilotContextTier } from "../../providers/github-copilot-context"; import { canonicalFastTierMarker, decideTier, type ResolvedFastPolicy } from "../../providers/fastwire"; import { debugProviderDiagnostic } from "../../lib/debug"; import { isDebugEnabled } from "../../lib/debug-settings"; @@ -53,6 +54,7 @@ export function buildOpenAIChatPassthroughRequest( stream: boolean, fastPolicy: ResolvedFastPolicy = fastPolicyForModel(provider, modelId, undefined, "chat"), fastMode?: boolean, + providerName?: string, ): AdapterRequest { const { url, headers, hasCredential } = openAIChatTransport(provider); @@ -153,7 +155,7 @@ export function buildOpenAIChatPassthroughRequest( body.stream_options = rawBody.stream_options; } - const bodyJson = JSON.stringify(body); + const bodyJson = JSON.stringify(applyGithubCopilotContextTier(body, provider, modelId, providerName)); if (isDebugEnabled()) { let host = "upstream"; diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 7540c3ac876..a3e5a85e1ca 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -3,6 +3,7 @@ import { nameRoutedIdentity, repairIdentityInResponsesBody, stripRoutedIdentity import { stripBracketedModelSuffix } from "../openai-chat"; import { normalizeOpenCodeGoAdditionalTools } from "../opencode-go-additional-tools"; import { isXaiResponsesDestination } from "../../providers/xai-transport"; +import { applyGithubCopilotContextTier } from "../../providers/github-copilot-context"; import { Buffer } from "node:buffer"; import type { IncomingMeta, ProviderAdapter } from "../base"; import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage, type TierDecision } from "../../types"; @@ -538,7 +539,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): if (!isCanonicalOpenAiForwardProvider(provider)) { validateFinalCustomToolCompatibility(finalBody, provider.supportsResponsesCustomTools); } - const body = JSON.stringify(finalBody); + const body = JSON.stringify(applyGithubCopilotContextTier(finalBody, provider, parsed.modelId, incoming.providerName)); const releaseBodyObservation = translatorBudget.observeExternallyCapped( "passthrough_serialization", Buffer.byteLength(body, "utf8"), diff --git a/src/cli/provider-runtime.ts b/src/cli/provider-runtime.ts index c8054a7643b..ff3d9f96d2c 100644 --- a/src/cli/provider-runtime.ts +++ b/src/cli/provider-runtime.ts @@ -1,4 +1,4 @@ -import { modelCapabilitiesConfigError } from "../config/provider-validation"; +import { contextTierRecordConfigError, modelCapabilitiesConfigError } from "../config/provider-validation"; import { CliUsageError, csv, @@ -42,7 +42,7 @@ const USAGE = `Usage: [--headers ] [--enabled ] [--live-models ] [--retain-models ] [--model --text-only] [--xai-chat ] - [--allow-private-network ] [--json] + [--allow-private-network ] [--model-context-tier ] [--json] ocx provider test [--json] ocx provider quota [--refresh] [--json] ocx provider resets [--limit ] [--json] @@ -73,6 +73,12 @@ async function edit(argv: string[], deps: RuntimeApiDeps): Promise { const liveModels = takeBooleanOption(args, "--live-models"); const allowPrivateNetwork = takeBooleanOption(args, "--allow-private-network"); const xaiChat = takeBooleanOption(args, "--xai-chat"); + const contextTierValues: string[] = []; + for (;;) { + const value = takeOption(args, "--model-context-tier"); + if (value === undefined) break; + contextTierValues.push(value); + } const textOnly = takeFlag(args, "--text-only"); const capabilityModel = takeOption(args, "--model"); rejectArgs(args, USAGE); @@ -83,6 +89,21 @@ async function edit(argv: string[], deps: RuntimeApiDeps): Promise { if (error) throw new CliUsageError(error, USAGE); patch.modelCapabilities = declaration; } + if (contextTierValues.length) { + if (name !== "github-copilot") throw new CliUsageError("--model-context-tier is valid only for provider github-copilot", USAGE); + const tiers: Record = Object.create(null); + for (const value of contextTierValues) { + const separator = value.indexOf("="); + const model = value.slice(0, separator); + const tier = value.slice(separator + 1); + if (separator < 1 || (tier !== "default" && tier !== "long_context")) + throw new CliUsageError("--model-context-tier must use model=default or model=long_context", USAGE); + tiers[model] = tier; + } + const error = contextTierRecordConfigError(tiers); + if (error) throw new CliUsageError(error, USAGE); + patch.modelContextTiers = tiers; + } if (xaiChat !== undefined) { if (name !== "xai") throw new CliUsageError("--xai-chat is valid only for provider xai", USAGE); patch.xaiResponsesOptIn = !xaiChat; diff --git a/src/cli/provider.ts b/src/cli/provider.ts index 83ea1027a75..5b0cab94498 100644 --- a/src/cli/provider.ts +++ b/src/cli/provider.ts @@ -245,6 +245,9 @@ async function handleAdd(args: string[]): Promise { if (existingProvider?.modelCapabilities !== undefined && provConfig.modelCapabilities === undefined) { provConfig.modelCapabilities = structuredClone(existingProvider.modelCapabilities); } + if (existingProvider?.modelContextTiers !== undefined && provConfig.modelContextTiers === undefined) { + provConfig.modelContextTiers = structuredClone(existingProvider.modelContextTiers); + } if (textOnly) { const modelId = capabilityModel ?? defaultModel ?? provConfig.defaultModel; if (!modelId) { diff --git a/src/codex/catalog/gather-capture.ts b/src/codex/catalog/gather-capture.ts index cdca77fb61a..7f67743cc0c 100644 --- a/src/codex/catalog/gather-capture.ts +++ b/src/codex/catalog/gather-capture.ts @@ -531,6 +531,7 @@ function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Reco defaultModel: prov.defaultModel ?? null, ctx: prov.contextWindow ?? null, ctxW: prov.modelContextWindows ?? null, + ctxTier: prov.modelContextTiers ?? null, maxIn: prov.modelMaxInputTokens ?? null, maxOut: prov.modelMaxOutputTokens ?? null, autoCompact: prov.modelAutoCompactTokenLimits ?? null, diff --git a/src/codex/catalog/model-hints.ts b/src/codex/catalog/model-hints.ts index 4e345578c9d..bd0e3692fc2 100644 --- a/src/codex/catalog/model-hints.ts +++ b/src/codex/catalog/model-hints.ts @@ -45,6 +45,7 @@ import type { FastPolicyAuthority } from "../../providers/fastwire"; import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../../providers/registry"; import { parseAntigravityAvailableModels, registerAntigravityDiscoveredWireModels } from "../../providers/antigravity-models"; import { applyProviderContextCap, providerContextCap, resolveUnknownRoutedContextWindow } from "../../providers/context-cap"; +import { githubCopilotCatalogContextWindow } from "../../providers/github-copilot-context"; import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget"; import { effectiveModelAliases } from "../../providers/default-aliases"; import { routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../../providers/slug-codec"; @@ -310,9 +311,8 @@ export function applyProviderConfigHints( ...modelWithoutServiceTier } = model; // 已发现窗口只允许被配置值压低;缺窗口时,已开的 Context cap 就是实际窗口。 - const discoveredWindow = typeof model.contextWindow === "number" && model.contextWindow > 0 - ? model.contextWindow - : undefined; + const discoveredWindow = githubCopilotCatalogContextWindow(name, prov, model.id, + typeof model.contextWindow === "number" && model.contextWindow > 0 ? model.contextWindow : undefined); const projectedLimits = clampObservedModelLimits(staticPolicy.model, { ...(discoveredWindow !== undefined ? { contextWindow: discoveredWindow } : {}), ...(typeof model.maxInputTokens === "number" && model.maxInputTokens > 0 ? { maxInputTokens: model.maxInputTokens } : {}), diff --git a/src/config/provider-validation.ts b/src/config/provider-validation.ts index b7c80140229..5f703ff2465 100644 --- a/src/config/provider-validation.ts +++ b/src/config/provider-validation.ts @@ -383,6 +383,19 @@ function capabilityRecord(value: unknown): value is Record { && [Object.prototype, null].includes(Object.getPrototypeOf(value)); } +/** Strict per-model context-tier values; PATCH accepts null to clear the entire map. */ +export function contextTierRecordConfigError(value: unknown, allowClear = false): string | null { + if (value === undefined || (allowClear && value === null)) return null; + if (!capabilityRecord(value)) return "modelContextTiers must be a plain object"; + if (Object.keys(value).length > MODEL_DISCOVERY_MAX_MODELS) return "modelContextTiers has too many models"; + for (const [id, tier] of Object.entries(value)) { + if (!isValidModelDiscoveryModelId(id) || ["__proto__", "prototype", "constructor"].includes(id)) + return "modelContextTiers keys must be exact non-reserved model ids"; + if (tier !== "default" && tier !== "long_context") return "modelContextTiers values must be default or long_context"; + } + return null; +} + /** Strict writes; only PATCH may carry deletion tombstones. Model IDs are exact. */ export function modelCapabilitiesConfigError(value: unknown, allowTombstones = false): string | null { if (value === undefined || (allowTombstones && value === null)) return null; diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index 2e1a4cf5c19..391ea44d451 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -10,6 +10,7 @@ import { normalizeNonBlankStringArray, normalizeAutoReviewModelOverrides, modelCapabilitiesConfigError, + contextTierRecordConfigError, mergeModelCapabilities, } from "../provider-validation"; import { isValidCodexAccountNamespaceTarget } from "../../codex/account-namespace-match"; @@ -279,6 +280,10 @@ const providerNoProxySchema = z.unknown().superRefine((value, ctx) => { */ export const providerConfigSchema = z.object({ modelCapabilities: modelCapabilitiesSchema.optional(), + modelContextTiers: z.unknown().superRefine((value, ctx) => { + const error = contextTierRecordConfigError(value); + if (error) ctx.addIssue({ code: "custom", message: error }); + }).optional().transform(value => value as OcxProviderConfig["modelContextTiers"]), pinnedReasoningEffort: pinnedReasoningEffortSchema.optional(), modelPinnedReasoningEfforts: modelPinnedEffortsSchema.optional(), // Validated rather than left to passthrough: an unrecognized strategy would otherwise diff --git a/src/images/loop.ts b/src/images/loop.ts index c75de66ccae..ecdbcc3cf73 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -514,6 +514,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise 0 ? supported : current; +} diff --git a/src/providers/model-rename-fields.ts b/src/providers/model-rename-fields.ts index 76d403884eb..323868a41fa 100644 --- a/src/providers/model-rename-fields.ts +++ b/src/providers/model-rename-fields.ts @@ -57,6 +57,7 @@ export const PROVIDER_MODEL_RENAME_ROLES = { modelPreset: "none", contextWindow: "none", modelContextWindows: "record", + modelContextTiers: "record", modelInputModalities: "record", modelCapabilities: "record", modelMaxInputTokens: "record", diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index c5bcce70d54..048abd5f7a7 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -1,5 +1,5 @@ import { providerRelativeSendPathConfigError } from "../config/provider-relative-send-path"; -import { modelCapabilitiesConfigError } from "../config/provider-validation"; +import { contextTierRecordConfigError, modelCapabilitiesConfigError } from "../config/provider-validation"; import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { initialModelSelection } from "../providers/initial-model-selection"; import { extractAccountId } from "../oauth/chatgpt"; @@ -693,6 +693,8 @@ export function providerManagementConfigError( return "provider must be a plain object"; } const raw = provider as Record; + const contextTiersError = contextTierRecordConfigError(raw.modelContextTiers); + if (contextTiersError) return contextTiersError; const capabilitiesError = modelCapabilitiesConfigError(raw.modelCapabilities); if (capabilitiesError) return capabilitiesError; const pinsError = providerReasoningPinsConfigError(raw); @@ -1021,6 +1023,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { modelPreset: "editor", contextWindow: "editor", modelContextWindows: "editor", + modelContextTiers: "editor", modelInputModalities: "editor", modelCapabilities: "editor", modelMaxInputTokens: "runtime", diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index fe5896a7758..3e50b5e02c0 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -336,6 +336,7 @@ export async function runNativeChatAttempt( requestedStream, fastPolicyForModel(activeProvider, route.modelId, route.providerName, "chat"), config.fastMode, + route.providerName, ); }; try { diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 100d9f7557c..39fd7a5371e 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -1,4 +1,4 @@ -import { modelCapabilitiesConfigError, mergeModelCapabilities } from "../../config/provider-validation"; +import { contextTierRecordConfigError, modelCapabilitiesConfigError, mergeModelCapabilities } from "../../config/provider-validation"; import { DECLARABLE_HOSTED_TOOL_TYPES } from "../../responses/hosted-tool-policy"; import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; @@ -530,6 +530,13 @@ function applyProviderPatchFields( else next.modelCapabilities = capabilities; touched = true; } + if (Object.hasOwn(rawBody, "modelContextTiers")) { + const error = contextTierRecordConfigError(rawBody.modelContextTiers, true); + if (error) return { error }; + if (rawBody.modelContextTiers === null) delete next.modelContextTiers; + else next.modelContextTiers = Object.assign(Object.create(null), next.modelContextTiers ?? {}, rawBody.modelContextTiers); + touched = true; + } if (Object.hasOwn(rawBody, "modelContextWindows")) { const value = rawBody.modelContextWindows; if (value === null) { @@ -933,6 +940,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { + const liveTiers = config.providers[name]?.modelContextTiers; + if (submittedModelContextTiers && liveTiers) candidate.modelContextTiers = Object.assign(Object.create(null), liveTiers, candidate.modelContextTiers ?? {}); + else if (!submittedModelContextTiers && liveTiers) candidate.modelContextTiers = { ...liveTiers }; + else if (!submittedModelContextTiers) delete candidate.modelContextTiers; + const draft = { ...(registrationDraft ?? config), providers: { ...config.providers, [name]: candidate }, ...(body.setDefault === true ? { defaultProvider: name } : {}) }; + const validation = validateConfigCandidate(draft); if (!validation.ok) { validationError = validation.error; return; } + try { + if (registrationDraft) { + for (const key of ["modelDiscovery", "disabledModels"] as const) { + if (Object.hasOwn(registrationDraft, key)) Object.defineProperty(config, key, { + value: registrationDraft[key], writable: true, enumerable: true, configurable: true, + }); + } } + config.providers[name] = candidate; + if (body.setDefault === true) config.defaultProvider = name; + (deps.saveConfigPreservingClaudeCode ?? save)(config); + } catch (error) { + if (rollback) { + if (previous) Object.defineProperty(config.providers, name, previous); + else delete config.providers[name]; + rollback(); + } + throw error; } - config.providers[name] = candidate; - if (body.setDefault === true) config.defaultProvider = name; - (deps.saveConfigPreservingClaudeCode ?? save)(config); - } catch (error) { - if (rollback) { - if (previous) Object.defineProperty(config.providers, name, previous); - else delete config.providers[name]; - rollback(); - } - throw error; - } + }); + if (validationError !== undefined) return jsonResponse({ error: validationError }, 400); reconcileLiveStateStores(); if (prov.apiKey && prov.apiKeyPool) { const { addProviderApiKey } = await import("../../providers/api-keys"); diff --git a/src/server/responses/adapter-continuation.ts b/src/server/responses/adapter-continuation.ts index f71c29cd9a7..7db1a602e16 100644 --- a/src/server/responses/adapter-continuation.ts +++ b/src/server/responses/adapter-continuation.ts @@ -179,6 +179,7 @@ export function createAdapterContinuations( try { continuationRequest = await transportState.activeAdapter.buildRequest(nextParsed, { headers: requestState.selectedForwardHeaders, + providerName: route.providerName, translatorBudget, ...(transportState.imageTierBias > 0 ? { imageTierBias: transportState.imageTierBias } : {}), }); diff --git a/src/server/responses/adapter-dispatch.ts b/src/server/responses/adapter-dispatch.ts index 1477c68f716..b836e619b2c 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -270,6 +270,7 @@ export async function prepareAdapterExchange( try { initialRequest = await transportState.activeAdapter.buildRequest(parsed, { headers: requestState.selectedForwardHeaders, + providerName: route.providerName, translatorBudget, abortSignal: upstream.signal, }); @@ -456,6 +457,7 @@ export async function prepareAdapterExchange( try { retryRequest = await transportState.activeAdapter.buildRequest(parsed, { headers: requestState.selectedForwardHeaders, + providerName: route.providerName, translatorBudget, abortSignal: upstream.signal, ...(transportState.imageTierBias > 0 ? { imageTierBias: transportState.imageTierBias } : {}), diff --git a/src/server/responses/core-codex-account.ts b/src/server/responses/core-codex-account.ts index 0a37b6d3f1a..47ee892258a 100644 --- a/src/server/responses/core-codex-account.ts +++ b/src/server/responses/core-codex-account.ts @@ -777,6 +777,7 @@ export async function retryCodexPoolOnAlternateAccount( } const request = await retryAdapter.buildRequest(parsed, { headers: retryHeaders, + providerName: route.providerName, translatorBudget: options.translatorBudget, }); recordAdapterReasoning(logCtx, request); diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index b78be9e0a97..a331cbaeaf8 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -327,7 +327,11 @@ export async function preparePassthroughExchange( const providerExecutedCallTypes = new Set(); let request: Awaited>; try { - request = await transportState.adapter.buildRequest(parsed, { headers: requestState.selectedForwardHeaders, translatorBudget }); + request = await transportState.adapter.buildRequest(parsed, { + headers: requestState.selectedForwardHeaders, + providerName: route.providerName, + translatorBudget, + }); } catch (error) { releaseCodexAuthContextProbeLease(admissionState.authCtx); // A tool catalog this proxy cannot lower onto one wire namespace is a client input error, and @@ -993,6 +997,7 @@ export async function preparePassthroughExchange( if (recovery !== "console-go-upload-retry") { request = await retryAdapter.buildRequest(parsed, { headers: requestState.selectedForwardHeaders, + providerName: route.providerName, translatorBudget, }); } @@ -1130,6 +1135,7 @@ export async function preparePassthroughExchange( try { request = await replayAdapter.buildRequest(parsed, { headers: requestState.selectedForwardHeaders, + providerName: route.providerName, translatorBudget, }); refreshRequestToolAliases(request); @@ -1274,6 +1280,7 @@ export async function preparePassthroughExchange( try { request = await refreshedAdapter.buildRequest(parsed, { headers: requestState.selectedForwardHeaders, + providerName: route.providerName, translatorBudget, }); refreshRequestToolAliases(request); diff --git a/src/server/responses/request-transport.ts b/src/server/responses/request-transport.ts index 59141353ce3..206a923963d 100644 --- a/src/server/responses/request-transport.ts +++ b/src/server/responses/request-transport.ts @@ -518,7 +518,7 @@ export async function prepareResponsesTransport( bindRouteReasoningReplayScope({ parsed: requestParsed, providerName: route.providerName, provider: route.provider, adapterName: nextAdapter.name, oauthCredentialSnapshot: replayOAuthCredentialSnapshot }); const rebuilt = await nextAdapter.buildRequest(requestParsed, { - headers: requestState.selectedForwardHeaders, translatorBudget, + headers: requestState.selectedForwardHeaders, providerName: route.providerName, translatorBudget, ...(imageTierBias > 0 ? { imageTierBias } : {}), }); const bodySize = checkOutboundBodySize(rebuilt.body, config.maxUpstreamBodyBytes); diff --git a/src/server/responses/sidecar-execution.ts b/src/server/responses/sidecar-execution.ts index e897a0ed541..771fb915e77 100644 --- a/src/server/responses/sidecar-execution.ts +++ b/src/server/responses/sidecar-execution.ts @@ -387,7 +387,7 @@ export async function executeResponsesSidecars( ); const imgResponse = await runWithImageBridge({ parsed, adapter: transportState.adapter, - incomingMeta: { headers: requestState.selectedForwardHeaders, abortSignal: options.abortSignal, translatorBudget }, + incomingMeta: { headers: requestState.selectedForwardHeaders, providerName: route.providerName, abortSignal: options.abortSignal, translatorBudget }, ...(imgPlan ? { plan: imgPlan } : {}), ...(vidPlan ? { videoPlan: vidPlan } : {}), forwardHeaders: requestState.selectedForwardHeaders, @@ -479,6 +479,7 @@ export async function executeResponsesSidecars( }), incomingMeta: { headers: requestState.selectedForwardHeaders, + providerName: route.providerName, abortSignal: options.abortSignal, translatorBudget, providerFetch: routedProviderFetch, diff --git a/src/types/provider.ts b/src/types/provider.ts index fb8e1f4d616..d66cfbbd999 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -595,6 +595,8 @@ export interface OcxProviderConfig { contextWindow?: number; /** Per-model fallback when context metadata is absent; otherwise caps the reported window. */ modelContextWindows?: Record; + /** Per-model Copilot upstream tier; only the github-copilot route sends it. */ + modelContextTiers?: Record; /** Model-specific Codex catalog input modalities, e.g. ["text"] or ["text", "image"]. */ modelInputModalities?: Record; modelCapabilities?: Record; diff --git a/structure/catalog.md b/structure/catalog.md index ff914873e15..6bd10f2f486 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -296,7 +296,7 @@ a label edit refresh Codex output. Raw `/v1/models` rows advertise positive safe capacity values in both Cursor's nested `capabilities` object and top-level discovery fields used by other clients. A model with a larger -opt-in context tier uses that effective long window in both shapes; invalid values are omitted. +opt-in context tier uses that effective long window in both shapes; invalid values are omitted. For `github-copilot`, `modelContextTiers` raises a selected long-context window only when an exact `modelContextWindows` value supplies per-model evidence; that value wins before the provider cap, while unknown models retain observed metadata. The gather fingerprint includes the map. Supported bare native GPT rows also consume `providers.openai.modelDisplayNames`. Retained sync and convergence pass the same map to the observed-state merge. After native normalization and diff --git a/structure/config.md b/structure/config.md index 5391e7addf5..70006e1b5c5 100644 --- a/structure/config.md +++ b/structure/config.md @@ -560,7 +560,7 @@ Provider `autoReviewModel` and `autoReviewModelOverrides` accept validated final Display-name validation retains prototype-shaped model IDs as data; reviewer-target map validation remains separate and rejects its reserved keys. ## Explicit per-model capability declarations -`modelCapabilities` on `src/types/provider.ts` stores exact model-ID entries with optional inputModalities, contextTier and video.processing axes. `src/config/provider-validation.ts` strictly validates writes and merges PATCH axes without sharing live objects; null map/model/axis/processing tombstones delete, while empty PATCH objects do nothing. Complete POST/PUT replacements reject tombstones. File reads retain valid axes; malformed explicit modalities restrict to text with a diagnostic. The two catalog writers receive explicit config and gather fingerprints include the map. This storage contract alone does not activate a context tier, advertise a larger window or enable video processing. +`modelCapabilities` on `src/types/provider.ts` stores exact model-ID entries with optional inputModalities, contextTier and video.processing axes. `src/config/provider-validation.ts` strictly validates writes and merges PATCH axes without sharing live objects; null map/model/axis/processing tombstones delete, while empty PATCH objects do nothing. Complete POST/PUT replacements reject tombstones. File reads retain valid axes; malformed explicit modalities restrict to text with a diagnostic. The two catalog writers receive explicit config and gather fingerprints include the map. This storage contract alone does not activate a context tier, advertise a larger window or enable video processing. Separately, `modelContextTiers` actively selects `default` or `long_context` for GitHub Copilot models: config and management writes validate exact IDs, PATCH merges entries and null clears the map, CLI edit writes the map, and OAuth login preserves it. The text-only consumer reads exact inputModalities declarations before legacy hints. CLI add/edit `--text-only` targets one model and preserves sibling declarations; `src/vision/eligibility.ts` routes declared text-only models into existing image-description or explicit-omission handling. Positive routed image declarations override stale candidate metadata, while native catalog authority retains its existing legacy policy. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index d44f7a84629..5007e78ffbd 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -2,6 +2,10 @@ `src/server/management/oauth-account-routes.ts` exposes Anthropic `routes` through both unified `/api/pool/settings` and legacy `/api/oauth/accounts/pool`. Omitted rules survive other setting writes, `null` clears them, and other pool kinds reject supplied rules. The unified DTO declares `routes` supported only for Anthropic and reports null otherwise. Config and management responses retain route names; request logs use only the rule’s 1-based `route:#` position. `src/cli/account-extended.ts` reads, replaces and clears these rules with `ocx account routes anthropic`; the server validates content. +The provider management API validates `modelContextTiers` as a strict per-model map, +merges PATCH entries, and preserves omitted entries on full provider saves. POST re-reads +the live tier map after destination validation under the config mutation lock, so a concurrent +PATCH clear is not restored. The CLI uses that API for GitHub Copilot tier edits. The dashboard has no tier control yet. Automatic activation retains its existing settings controls; dashboard quota queries remain independent. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index ff380e54677..b4754785f33 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -2,6 +2,10 @@ For Anthropic OAuth, `src/oauth/anthropic-routing.ts` applies the first matching `anthropicAccountPool.routes` rule to every eligible pick. The declared account order is stable while its candidates remain eligible; active, manual, affinity, quota and strategy preferences only choose inside that set. An explicit fallback widens an empty route to the ordinary pool, and fill-first then advances in ordinary pool order from the active account. A missing eligible route fails locally without that fallback. The rules are operator allowlists, not provider entitlement evidence. Request logs use `route:#` for the 1-based rule position, not the operator name. +GitHub Copilot `modelContextTiers` is selected per upstream model. The Chat and Responses +adapters set `contextTier` only when the canonical routed provider is `github-copilot` +and a tier is configured. Otherwise passthrough retains caller-supplied values. The server carries provider identity +through initial builds, retries, continuations, and sidecar builds. The coding-agent stream parser buffers each tool-use block by its content-block index and emits a complete start/delta/end sequence on closure. Distinct indices can interleave. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index c06c100fbc0..6731d2de830 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -14,7 +14,7 @@ When a successful streamed native response has a missing or unrecognized non-JSO Optional Codex memory selection enters `src/server/responses/request-prepare.ts` for HTTP and WebSocket frames, as specified in [memory phase routing](responses-failover.md#memory-phase-routing); an unset phase keeps its existing route and starts no background work. Responses request preparation stabilizes incoming `` under `skills.catalog_refresh`: `per_session` (default) reuses the first received catalog for a conversation; `per_turn` leaves the supplied catalog unchanged. Other instruction sections and user/tool content remain untouched. Requests without a reliable conversation identity bypass snapshots; shared prompt-cache cohorts are not conversation identities. Only a body with exactly one catalog block across its instructions and developer/system content takes part; two or more pass through unchanged. A known snapshot is substituted before parsing, but a new catalog is stored only when preparation reaches its success return, so a request rejected by parsing or admission pins nothing. Without a named principal, snapshots are shared by conversation id only on a server that requires no data-plane auth. Snapshots are process-local, expire after four idle hours, and use bounded LRU retention; oversized blocks bypass caching. The dashboard's `src/codex/prompt-layers.ts` and `src/codex/prompt-text-probe.ts` continue observing current files for previews and do not own session snapshots. `/v1/responses` is the main Codex-facing endpoint. The server parses Responses input, routes to a provider, lets the selected adapter speak the upstream protocol, then bridges adapter events back to -Responses-compatible streaming output. For an opted-in key-auth provider, a hosted-search continuation stays bound to the API-key selection that served the first leg; the contract is the [hosted-search continuation binding](../providers-and-adapters.md#hosted-search-continuation-binding). +Responses-compatible streaming output. The routed provider name follows every adapter build, including retries and continuations; only `github-copilot` injects a configured `modelContextTiers` value as upstream `contextTier`, while unconfigured passthrough retains the caller field. For an opted-in key-auth provider, a hosted-search continuation stays bound to the API-key selection that served the first leg; the contract is the [hosted-search continuation binding](../providers-and-adapters.md#hosted-search-continuation-binding). The `openai-responses` adapter preserves the incoming `User-Agent` as a non-credential fallback in both key and forward modes. A configured provider header with that name wins case-insensitively; diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index fd96824c854..460fb292c96 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -633,6 +633,16 @@ describe("headless GUI parity CLI", () => { expect(runtime.requests).toHaveLength(0); }); + test("provider edit sends only validated Copilot context tiers", async () => { + const { requests, deps } = fakeRuntime(); + expect(await handleProviderRuntimeCommand("edit", ["github-copilot", "--model-context-tier", "gpt-5.6-luna=long_context", "--model-context-tier", "gpt-5.5=default"], deps)).toBe(0); + expect(requests).toEqual([{ path: "/api/providers?name=github-copilot", method: "PATCH", + body: { modelContextTiers: { "gpt-5.6-luna": "long_context", "gpt-5.5": "default" } } }]); + expect(await handleProviderRuntimeCommand("edit", ["github-copilot", "--model-context-tier", "gpt-5.5=wide"], deps)).toBe(2); + expect(await handleProviderRuntimeCommand("edit", ["openai", "--model-context-tier", "gpt-5.5=default"], deps)).toBe(2); + expect(requests).toHaveLength(1); + }); + test("provider edit --headers sends the parsed block and - clears it", async () => { const runtime = fakeRuntime(); const code = await handleProviderRuntimeCommand("edit", [ diff --git a/tests/cli/cli-provider.test.ts b/tests/cli/cli-provider.test.ts index 7c9d75affad..3c17e82ee0a 100644 --- a/tests/cli/cli-provider.test.ts +++ b/tests/cli/cli-provider.test.ts @@ -694,12 +694,13 @@ describe("ocx provider add --sync", () => { test("provider add --force preserves all explicit model capability axes", () => { const declarations = { ModelA: { inputModalities: ["text"], contextTier: "long_context", video: { processing: "agentic" } }, modela: { inputModalities: ["text", "image"] } }; const { dir } = freshConfig({ defaultProvider: "caps", providers: { caps: { - adapter: "openai-chat", baseUrl: "https://example.test/v1", modelCapabilities: declarations, + adapter: "openai-chat", baseUrl: "https://example.test/v1", modelCapabilities: declarations, modelContextTiers: { ModelA: "long_context" }, } } }); try { const result = runCli(["provider", "add", "caps", "--adapter", "openai-chat", "--base-url", "https://example.test/v1", "--force", "--json"], { OPENCODEX_HOME: dir }); expect(result.status, result.stderr).toBe(0); expect(readConfig(dir).providers.caps.modelCapabilities).toEqual(declarations); + expect(readConfig(dir).providers.caps.modelContextTiers).toEqual({ ModelA: "long_context" }); } finally { removeTreeWithRetry(dir); } }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 85e90994a6f..16b90f1b509 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -835,6 +835,7 @@ "gemini-web-search.test.ts": "adapters/google", "generic-oauth-failover.test.ts": "oauth", "github-copilot-account-origin.test.ts": "providers/github-copilot", + "github-copilot-context-tier.test.ts": "providers/github-copilot", "github-copilot-oauth.test.ts": "providers/github-copilot", "github-copilot-sse-rewrite.test.ts": "providers/github-copilot", "github-copilot-stream-contract.test.ts": "providers/github-copilot", diff --git a/tests/images/loop.test.ts b/tests/images/loop.test.ts index f5a06d867ae..cda36e3373e 100644 --- a/tests/images/loop.test.ts +++ b/tests/images/loop.test.ts @@ -1265,3 +1265,37 @@ describe("runWithImageBridge — runTurn adapter", () => { expect(parsed._cursorConversationId).toBe("conv-from-first-turn"); }); }); + +describe.each(["runTurn", "parseStream"] as const)("image-loop provider identity — %s", mode => { + test("forwards the routed provider identity to every bridge iteration", async () => { + const seen: (string | undefined)[] = []; + let iteration = 0; + const eventsFor = (): AdapterEvent[] => iteration++ === 0 + ? imageCallEvents + : [{ type: "text_delta", text: "finished" }, { type: "done" }]; + const adapter: ProviderAdapter = { + name: "provider-identity-fixture", + buildRequest: async (_parsed, incoming) => { + seen.push(incoming.providerName); + return { url: "https://example.invalid/model", method: "POST", headers: {}, body: "{}" }; + }, + fetchResponse: async () => new Response("{}", { status: 200, headers: { "content-type": "application/json" } }), + parseStream: async function* (): AsyncGenerator { for (const e of eventsFor()) yield e; }, + ...(mode === "runTurn" ? { + runTurn: async (_parsed: OcxParsedRequest, incoming: IncomingMeta, emit: (event: AdapterEvent) => void) => { + seen.push(incoming.providerName); + for (const e of eventsFor()) emit(e); + }, + } : {}), + }; + const response = await runWithImageBridge({ + parsed: makeParsed(), + adapter, + plan, + incomingMeta: { headers: new Headers(), providerName: "github-copilot", translatorBudget: createTestTranslatorBudget() }, + }); + await response.text(); + expect(seen.length).toBe(2); + expect(seen).toEqual(["github-copilot", "github-copilot"]); + }); +}); diff --git a/tests/oauth/oauth-upsert-preserves-api-key.test.ts b/tests/oauth/oauth-upsert-preserves-api-key.test.ts index 710089a7add..06cc51facce 100644 --- a/tests/oauth/oauth-upsert-preserves-api-key.test.ts +++ b/tests/oauth/oauth-upsert-preserves-api-key.test.ts @@ -76,6 +76,16 @@ describe("upsertOAuthProvider credential preservation", () => { expect(provider.authMode).toBe("key"); }); + test("keeps Copilot context tiers across an OAuth upsert without sharing the old map", () => { + const config = configWithKey("github-copilot", "openai-chat", "https://api.githubcopilot.com"); + const tiers = { "gpt-5.6-luna": "long_context" } as const; + config.providers["github-copilot"]!.modelContextTiers = { ...tiers }; + const before = config.providers["github-copilot"]!.modelContextTiers; + upsertOAuthProvider(config, "github-copilot"); + expect(config.providers["github-copilot"]!.modelContextTiers).toEqual(tiers); + expect(config.providers["github-copilot"]!.modelContextTiers).not.toBe(before); + }); + test("carries user-configured modelCosts across a re-login upsert", () => { const config = configWithKey("xai", "openai-chat", "https://api.x.ai/v1"); const costs = { "grok-4": { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0 } }; diff --git a/tests/providers/github-copilot/github-copilot-context-tier.test.ts b/tests/providers/github-copilot/github-copilot-context-tier.test.ts new file mode 100644 index 00000000000..8770dd87c91 --- /dev/null +++ b/tests/providers/github-copilot/github-copilot-context-tier.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "bun:test"; +import { buildOpenAIChatPassthroughRequest, createOpenAIChatAdapter as createOpenAIChatAdapterProduction } from "../../../src/adapters/openai-chat"; +import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../../../src/adapters/openai-responses"; +import { applyProviderConfigHints } from "../../../src/codex/catalog/provider-fetch"; +import { applyGithubCopilotContextTier } from "../../../src/providers/github-copilot-context"; +import type { OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; +import { withTestTranslatorBudget } from "../../helpers/translator-budget"; + +const createOpenAIChatAdapter = (...args: Parameters) => + withTestTranslatorBudget(createOpenAIChatAdapterProduction(...args)); +const createResponsesPassthroughAdapter = (...args: Parameters) => + withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args)); + +const MODEL = "gpt-5.6-luna"; + +function parsed(modelId = MODEL): OcxParsedRequest { + return { + modelId, + context: { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, + stream: false, + options: {}, + _rawBody: { model: modelId, input: "hello" }, + }; +} + +function provider(overrides: Partial = {}): OcxProviderConfig { + return { + adapter: "openai-responses", + baseUrl: "https://api.githubcopilot.com", + authMode: "oauth", + modelContextTiers: { [MODEL]: "long_context" }, + ...overrides, + }; +} + +describe("GitHub Copilot context tiers", () => { + test("adds the selected tier to Responses requests and overrides a caller value", async () => { + const input = parsed(); + input._rawBody = { model: MODEL, input: "hello", contextTier: "default" }; + const request = await createResponsesPassthroughAdapter(provider()).buildRequest(input, { providerName: "github-copilot" }); + expect(JSON.parse(request.body)).toMatchObject({ contextTier: "long_context" }); + }); + + test("adds the selected tier to Chat Completions requests too", async () => { + const request = await createOpenAIChatAdapter(provider({ adapter: "openai-chat", apiKey: "test-key" })) + .buildRequest(parsed(), { providerName: "github-copilot" }); + expect(JSON.parse(request.body)).toMatchObject({ contextTier: "long_context" }); + }); + + test("forwards the tier on the native Chat passthrough path", () => { + const copilot = provider({ adapter: "openai-chat", apiKey: "test-key" }); + const body = { model: MODEL, messages: [{ role: "user", content: "hello" }] }; + const request = buildOpenAIChatPassthroughRequest(copilot, body, MODEL, false, undefined, undefined, "github-copilot"); + expect(JSON.parse(request.body)).toHaveProperty("contextTier", "long_context"); + const unrelated = buildOpenAIChatPassthroughRequest(copilot, body, MODEL, false, undefined, undefined, "openai"); + expect(JSON.parse(unrelated.body)).not.toHaveProperty("contextTier"); + }); + + test("supports the explicit default tier and leaves unconfigured models untouched", async () => { + const defaultRequest = await createResponsesPassthroughAdapter(provider({ modelContextTiers: { [MODEL]: "default" } })) + .buildRequest(parsed(), { providerName: "github-copilot" }); + expect(JSON.parse(defaultRequest.body)).toMatchObject({ contextTier: "default" }); + + const otherRequest = await createResponsesPassthroughAdapter(provider()).buildRequest(parsed("gpt-5.5"), { providerName: "github-copilot" }); + expect(JSON.parse(otherRequest.body)).not.toHaveProperty("contextTier"); + }); + + test("does not inject the Copilot tier into another provider", async () => { + const request = await createResponsesPassthroughAdapter(provider()).buildRequest(parsed(), { providerName: "openai" }); + expect(JSON.parse(request.body)).not.toHaveProperty("contextTier"); + }); + + test.each(["github-copilot", "other-openai-compatible"])( + "preserves caller contextTier without a configured tier on %s", async providerName => { + const input = parsed(); + input._rawBody = { model: MODEL, input: "hello", contextTier: "caller-choice" }; + expect(applyGithubCopilotContextTier(input._rawBody, provider({ modelContextTiers: undefined }), MODEL, providerName)) + .toBe(input._rawBody); + const request = await createResponsesPassthroughAdapter(provider({ modelContextTiers: undefined })) + .buildRequest(input, { providerName }); + expect(JSON.parse(request.body)).toHaveProperty("contextTier", "caller-choice"); + }, + ); + + test("long_context needs exact model window evidence and still obeys both caps", () => { + const row = { provider: "github-copilot", id: MODEL, contextWindow: 200_000 }; + const withoutEvidence = applyProviderConfigHints("github-copilot", provider(), row, 400_000); + expect(withoutEvidence.contextWindow).toBe(200_000); + expect(withoutEvidence.contextCapped).toBe(false); + + const supported = applyProviderConfigHints("github-copilot", provider({ + modelContextWindows: { [MODEL]: 1_000_000 }, + }), row, 400_000); + expect(supported.contextWindow).toBe(400_000); + expect(supported.contextCap).toBe(400_000); + expect(supported.contextCapped).toBe(true); + + const narrowed = applyProviderConfigHints("github-copilot", provider({ + modelContextWindows: { [MODEL]: 150_000 }, + }), row, 400_000); + expect(narrowed.contextWindow).toBe(150_000); + expect(narrowed.contextCapped).toBe(false); + + const normal = applyProviderConfigHints("github-copilot", provider({ + modelContextTiers: { [MODEL]: "default" }, modelContextWindows: { [MODEL]: 1_000_000 }, + }), row, 400_000); + expect(normal.contextWindow).toBe(200_000); + }); +}); diff --git a/tests/server/management-provider-atomicity.test.ts b/tests/server/management-provider-atomicity.test.ts index a7b34dac2ae..b9737251b04 100644 --- a/tests/server/management-provider-atomicity.test.ts +++ b/tests/server/management-provider-atomicity.test.ts @@ -69,10 +69,10 @@ function harness(config = fixture(), save: (config: OcxConfig) => void = () => { clearThreadAccountMap: () => { events.push("threads"); }, primeCodexPoolQuotas: async () => { events.push("prime"); }, }; - async function patch(body: unknown, name = "relay") { + async function request(method: "PATCH" | "POST", body: unknown, name = "relay") { const url = new URL(`http://127.0.0.1/api/providers?name=${name}`); return handleProviderRoutes({ - req: new Request(url, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }), + req: new Request(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }), url, config, deps, version: "test", trustedLoopbackIngress: true, guiSessionIssuance: null, convergeCodexCatalog: async () => { events.push("catalog"); @@ -81,7 +81,8 @@ function harness(config = fixture(), save: (config: OcxConfig) => void = () => { syncClaudeAgentDefsBestEffort: async () => {}, }); } - return { config, patch, events, reconcile }; + return { config, patch: (body: unknown, name?: string) => request("PATCH", body, name), + post: (body: unknown) => request("POST", body), events, reconcile }; } describe("provider PATCH durable atomicity", () => { @@ -361,4 +362,32 @@ describe("provider PATCH durable atomicity", () => { }); expect(config.providers.relay).toEqual(persisted!.providers.relay); }); + + test.each([false, true])("POST uses a concurrent tier clear while validation awaits (submitted=%s)", async submitted => { + let entered!: () => void; + let release!: () => void; + const waiting = new Promise(resolve => { entered = resolve; }); + const gate = new Promise(resolve => { release = resolve; }); + spyOn(destinationPolicy, "providerDestinationResolvedError").mockImplementation(async (_name, candidate) => { + if (candidate.note === "delayed-overwrite") { entered(); await gate; } + return null; + }); + const config = fixture(); + config.providers.relay.modelContextTiers = { "relay-model": "long_context" }; + let persisted: OcxConfig | undefined; + const h = harness(config, saved => { persisted = structuredClone(saved); }); + const delayed = h.post({ name: "relay", provider: { + adapter: "openai-chat", baseUrl: "https://relay.example/v1", note: "delayed-overwrite", + ...(submitted ? { modelContextTiers: { "relay-model": "default" } } : {}), + } }); + await waiting; + try { + expect((await h.patch({ modelContextTiers: null }))?.status).toBe(200); + expect(config.providers.relay.modelContextTiers).toBeUndefined(); + } finally { release(); } + expect((await delayed)?.status).toBe(200); + const expected = submitted ? { "relay-model": "default" } : undefined; + expect(config.providers.relay.modelContextTiers).toEqual(expected); + expect(persisted?.providers.relay.modelContextTiers).toEqual(expected); + }); }); diff --git a/tests/server/management-provider-validation.test.ts b/tests/server/management-provider-validation.test.ts index 1e09a68bd0e..611121994db 100644 --- a/tests/server/management-provider-validation.test.ts +++ b/tests/server/management-provider-validation.test.ts @@ -1912,6 +1912,21 @@ describe("provider management validation", () => { } }); + test("an omitted modelContextTiers keeps the saved map", async () => { + freshHome(); + const server = startServer(0); + try { + expect((await seedProvider(server.url, { + modelContextTiers: { "gpt-5.6-luna": "long_context" }, + })).status).toBe(200); + expect((await seedProvider(server.url, {})).status).toBe(200); + expect(loadConfig().providers["opencode-go"]?.modelContextTiers) + .toEqual({ "gpt-5.6-luna": "long_context" }); + } finally { + await server.stop(true); + } + }); + test("a submitted modelContextWindows updates that key and keeps the others", async () => { freshHome(); const server = startServer(0); @@ -2079,6 +2094,9 @@ describe("provider management validation", () => { ["map-shape", { ...canonicalDirect, modelContextWindows: [] }], ["map-value", { ...canonicalDirect, modelContextWindows: { "gpt-5.6-sol": "wide" } }], ["map-key", { ...canonicalDirect, modelContextWindows: { " ": 500_000 } }], + ["tier-shape", { ...canonicalDirect, modelContextTiers: [] }], + ["tier-value", { ...canonicalDirect, modelContextTiers: { "gpt-5.6-sol": "wide" } }], + ["tier-key", { ...canonicalDirect, modelContextTiers: { " ": "default" } }], ["soft-map-shape", { ...canonicalDirect, modelAutoCompactTokenLimits: [] }], ["soft-map-value", { ...canonicalDirect, modelAutoCompactTokenLimits: { "gpt-5.6-sol": 1e100 } }], ["soft-map-key", { ...canonicalDirect, modelAutoCompactTokenLimits: { "team/gpt-5.6-sol": 120_000 } }], @@ -4190,6 +4208,7 @@ describe("provider management validation", () => { models: ["wide", "narrow"], contextWindow: 256_000, modelContextWindows: { narrow: 64_000 }, + modelContextTiers: { narrow: "default" }, modelAutoCompactTokenLimits: { narrow: 32_000 }, modelSupportsServiceTier: { narrow: false }, }, @@ -4214,11 +4233,13 @@ describe("provider management validation", () => { name: string; contextWindow?: number; modelContextWindows?: Record; + modelContextTiers?: Record; modelAutoCompactTokenLimits?: Record; }>; expect(rows.find(row => row.name === "relay")).toMatchObject({ contextWindow: 256_000, modelContextWindows: { narrow: 64_000 }, + modelContextTiers: { narrow: "default" }, modelAutoCompactTokenLimits: { narrow: 32_000 }, modelSupportsServiceTier: { narrow: false }, }); @@ -4226,6 +4247,7 @@ describe("provider management validation", () => { const updated = await request("PATCH", { contextWindow: 350_000, modelContextWindows: { wide: 350_000 }, + modelContextTiers: { wide: "long_context" }, modelAutoCompactTokenLimits: { wide: 100_000 }, modelSupportsServiceTier: { wide: true }, }); @@ -4233,12 +4255,14 @@ describe("provider management validation", () => { expect(liveConfig.providers.relay).toMatchObject({ contextWindow: 350_000, modelContextWindows: { wide: 350_000, narrow: 64_000 }, + modelContextTiers: { wide: "long_context", narrow: "default" }, modelAutoCompactTokenLimits: { wide: 100_000, narrow: 32_000 }, modelSupportsServiceTier: { wide: true, narrow: false }, }); expect(loadConfig().providers.relay).toMatchObject({ contextWindow: 350_000, modelContextWindows: { wide: 350_000, narrow: 64_000 }, + modelContextTiers: { wide: "long_context", narrow: "default" }, modelAutoCompactTokenLimits: { wide: 100_000, narrow: 32_000 }, modelSupportsServiceTier: { wide: true, narrow: false }, }); @@ -4253,6 +4277,9 @@ describe("provider management validation", () => { { modelContextWindows: { wide: 1e100 } }, { modelContextWindows: { "": 100_000 } }, { modelContextWindows: { wide: -1 } }, + { modelContextTiers: [] }, + { modelContextTiers: { wide: "unsupported" } }, + { modelContextTiers: { "": "default" } }, { modelAutoCompactTokenLimits: { wide: 1e100 } }, { modelAutoCompactTokenLimits: { "": 100_000 } }, { modelAutoCompactTokenLimits: { constructor: 100_000 } }, @@ -4264,6 +4291,7 @@ describe("provider management validation", () => { expect(liveConfig.providers.relay).toMatchObject({ contextWindow: 350_000, modelContextWindows: { wide: 350_000, narrow: 64_000 }, + modelContextTiers: { wide: "long_context", narrow: "default" }, modelAutoCompactTokenLimits: { wide: 100_000, narrow: 32_000 }, modelSupportsServiceTier: { wide: true, narrow: false }, }); @@ -4281,12 +4309,14 @@ describe("provider management validation", () => { const cleared = await request("PATCH", { contextWindow: null, modelContextWindows: null, + modelContextTiers: null, modelAutoCompactTokenLimits: null, modelSupportsServiceTier: null, }); expect(cleared?.status).toBe(200); expect(liveConfig.providers.relay.contextWindow).toBeUndefined(); expect(liveConfig.providers.relay.modelContextWindows).toBeUndefined(); + expect(liveConfig.providers.relay.modelContextTiers).toBeUndefined(); expect(liveConfig.providers.relay.modelAutoCompactTokenLimits).toBeUndefined(); expect(liveConfig.providers.relay.modelSupportsServiceTier).toBeUndefined(); expect(loadConfig().providers.relay.modelAutoCompactTokenLimits).toBeUndefined();