From d1beebafb974a01ba88449378e5522cda3df910a Mon Sep 17 00:00:00 2001 From: Menci Date: Sat, 1 Aug 2026 15:12:15 +0800 Subject: [PATCH] fix(provider-copilot): drop the obsolete Vertex structured-output strips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Messages boundary deleted `output_config.format` and tool `strict: true` from every request. Both existed because a Vertex-routed request tripped the GCP org policy `constraints/vertexai.allowedPartnerModelFeatures`, which denied the `structured_outputs` partner feature with a 400 FAILED_PRECONDITION. Copilot picks the backend per request, so callers lost the grammar-constrained guarantee on every model to insure against a backend they might not even draw. That policy no longer denies the feature. A sampler that has run every two hours since Jul 27 against the live catalog, from two egresses, recorded the change as a single clean transition: the last org-policy rejection landed at 2026-07-31T14:37Z and every Vertex-served request since 16:38Z has returned 200, on both egresses, with the rejection counters flat across the rounds that followed. The message-id infix (`msg_vrtx_*` / `msg_bdrk_*` / bare `msg_01*`) attributes each sample to its serving deployment, so these are Vertex-served successes rather than requests that happened to route elsewhere. With the upstream accepting the fields, stripping them is pure loss: clients ask for a schema-constrained reply and silently receive an unconstrained one. Remove both interceptors rather than gate them — a gate would still need a model-id predicate, and the failure never tracked the model. The policy lives in GitHub's own GCP project and can be flipped back without notice, so the sampler keeps running against the same matrix. Reinstating the strips is a revert of this commit. --- .../strip-structured-output-format_test.ts | 76 ------------------- .../messages/strip-tool-strict_test.ts | 51 ------------- .../src/interceptors/messages/index.ts | 4 - .../strip-structured-output-format.ts | 39 ---------- .../messages/strip-tool-strict.ts | 20 ----- 5 files changed, 190 deletions(-) delete mode 100644 packages/provider-copilot/__tests__/interceptors/messages/strip-structured-output-format_test.ts delete mode 100644 packages/provider-copilot/__tests__/interceptors/messages/strip-tool-strict_test.ts delete mode 100644 packages/provider-copilot/src/interceptors/messages/strip-structured-output-format.ts delete mode 100644 packages/provider-copilot/src/interceptors/messages/strip-tool-strict.ts diff --git a/packages/provider-copilot/__tests__/interceptors/messages/strip-structured-output-format_test.ts b/packages/provider-copilot/__tests__/interceptors/messages/strip-structured-output-format_test.ts deleted file mode 100644 index 4cdaeb370..000000000 --- a/packages/provider-copilot/__tests__/interceptors/messages/strip-structured-output-format_test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { test } from 'vitest'; - -import { withStructuredOutputFormatStripped } from '../../../src/interceptors/messages/strip-structured-output-format.ts'; -import type { MessagesBoundaryCtx } from '../../../src/interceptors/messages/types.ts'; -import type { ProtocolFrame } from '@floway-dev/protocols/common'; -import type { MessagesPayload, MessagesStreamEvent } from '@floway-dev/protocols/messages'; -import type { ExecuteResult } from '@floway-dev/provider'; -import { eventResult } from '@floway-dev/provider'; -import { assertEquals, stubProviderModel, testTelemetryModelIdentity } from '@floway-dev/test-utils'; - -const stubRequest = {}; - -const okEvents = (): Promise>> => - Promise.resolve(eventResult((async function* (): AsyncGenerator> {})(), testTelemetryModelIdentity)); - -const invocation = (payload: MessagesPayload): MessagesBoundaryCtx => ({ - payload, - headers: new Headers(), - model: stubProviderModel({ endpoints: { messages: {} } }), -}); - -const jsonSchemaFormat = { - type: 'json_schema', - schema: { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'], additionalProperties: false }, -}; - -test('strips output_config.format and drops an emptied container', async () => { - const ctx = invocation({ - model: 'claude-test', - max_tokens: 10, - messages: [{ role: 'user', content: 'hi' }], - output_config: { format: jsonSchemaFormat } as MessagesPayload['output_config'], - }); - - await withStructuredOutputFormatStripped(ctx, stubRequest, okEvents); - - assertEquals(ctx.payload.output_config, undefined); -}); - -test('preserves sibling output_config.effort', async () => { - const ctx = invocation({ - model: 'claude-test', - max_tokens: 10, - messages: [{ role: 'user', content: 'hi' }], - output_config: { effort: 'medium', format: jsonSchemaFormat } as MessagesPayload['output_config'], - }); - - await withStructuredOutputFormatStripped(ctx, stubRequest, okEvents); - - assertEquals(ctx.payload.output_config, { effort: 'medium' }); -}); - -test('no-op when output_config is absent', async () => { - const ctx = invocation({ - model: 'claude-test', - max_tokens: 10, - messages: [{ role: 'user', content: 'hi' }], - }); - - await withStructuredOutputFormatStripped(ctx, stubRequest, okEvents); - - assertEquals(ctx.payload.output_config, undefined); -}); - -test('no-op when output_config carries only sibling fields', async () => { - const ctx = invocation({ - model: 'claude-test', - max_tokens: 10, - messages: [{ role: 'user', content: 'hi' }], - output_config: { effort: 'low' }, - }); - - await withStructuredOutputFormatStripped(ctx, stubRequest, okEvents); - - assertEquals(ctx.payload.output_config, { effort: 'low' }); -}); diff --git a/packages/provider-copilot/__tests__/interceptors/messages/strip-tool-strict_test.ts b/packages/provider-copilot/__tests__/interceptors/messages/strip-tool-strict_test.ts deleted file mode 100644 index 44110c891..000000000 --- a/packages/provider-copilot/__tests__/interceptors/messages/strip-tool-strict_test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { test } from 'vitest'; - -import { withToolStrictStripped } from '../../../src/interceptors/messages/strip-tool-strict.ts'; -import type { MessagesBoundaryCtx } from '../../../src/interceptors/messages/types.ts'; -import type { ProtocolFrame } from '@floway-dev/protocols/common'; -import type { MessagesPayload, MessagesStreamEvent } from '@floway-dev/protocols/messages'; -import type { ExecuteResult } from '@floway-dev/provider'; -import { eventResult } from '@floway-dev/provider'; -import { assertEquals, stubProviderModel, testTelemetryModelIdentity } from '@floway-dev/test-utils'; - -const stubRequest = {}; - -const okEvents = (): Promise>> => - Promise.resolve(eventResult((async function* (): AsyncGenerator> {})(), testTelemetryModelIdentity)); - -const invocation = (payload: MessagesPayload): MessagesBoundaryCtx => ({ - payload, - headers: new Headers(), - model: stubProviderModel({ endpoints: { messages: {} } }), -}); - -test('strips strict from client tools while preserving the rest', async () => { - const ctx = invocation({ - model: 'claude-test', - max_tokens: 10, - messages: [{ role: 'user', content: 'hi' }], - tools: [ - { name: 'a', input_schema: { type: 'object' }, strict: true }, - { name: 'b', description: 'keep me', input_schema: { type: 'object', properties: {} } }, - ], - }); - - await withToolStrictStripped(ctx, stubRequest, okEvents); - - assertEquals(ctx.payload.tools, [ - { name: 'a', input_schema: { type: 'object' } }, - { name: 'b', description: 'keep me', input_schema: { type: 'object', properties: {} } }, - ]); -}); - -test('no-op when payload has no tools', async () => { - const ctx = invocation({ - model: 'claude-test', - max_tokens: 10, - messages: [{ role: 'user', content: 'hi' }], - }); - - await withToolStrictStripped(ctx, stubRequest, okEvents); - - assertEquals(ctx.payload.tools, undefined); -}); diff --git a/packages/provider-copilot/src/interceptors/messages/index.ts b/packages/provider-copilot/src/interceptors/messages/index.ts index 1a6dc687c..d26ab30c5 100644 --- a/packages/provider-copilot/src/interceptors/messages/index.ts +++ b/packages/provider-copilot/src/interceptors/messages/index.ts @@ -16,8 +16,6 @@ import { withInteractionIdHeaderSet } from './set-interaction-id-header.ts'; import { withVisionHeaderSet } from './set-vision-header.ts'; import { withCacheControlExtensionsStripped } from './strip-cache-control-extensions.ts'; import { withEagerInputStreamingStripped } from './strip-eager-input-streaming.ts'; -import { withStructuredOutputFormatStripped } from './strip-structured-output-format.ts'; -import { withToolStrictStripped } from './strip-tool-strict.ts'; import type { CopilotMessagesBoundaryInterceptor, CopilotMessagesCountTokensBoundaryInterceptor } from './types.ts'; // Order rationale, split into two lanes that run back-to-back: @@ -66,8 +64,6 @@ export const COPILOT_MESSAGES_BOUNDARY = [ withTopLevelCacheControlApplied, withCacheControlExtensionsStripped, withEagerInputStreamingStripped, - withToolStrictStripped, - withStructuredOutputFormatStripped, withVisionHeaderSet, withInitiatorHeaderSet, withAnthropicBetaHeaderFiltered, diff --git a/packages/provider-copilot/src/interceptors/messages/strip-structured-output-format.ts b/packages/provider-copilot/src/interceptors/messages/strip-structured-output-format.ts deleted file mode 100644 index 74be28aa7..000000000 --- a/packages/provider-copilot/src/interceptors/messages/strip-structured-output-format.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { CopilotMessagesBoundaryInterceptor } from './types.ts'; - -/** - * Anthropic's structured outputs (beta `structured-outputs-2025-12-15`) - * surface a `output_config.format` body field carrying a JSON Schema. - * Copilot load-balances `/v1/messages` between Vertex AI and other - * backends; when a request lands on Vertex, GCP organization policy - * `constraints/vertexai.allowedPartnerModelFeatures` denies the - * `structured_outputs` partner feature and returns a 400 - * `FAILED_PRECONDITION`. Stripping is the only deterministic fix: - * a retry might re-roll the routing dice but doesn't guarantee a - * non-Vertex backend on the second try. - * - * The body field is the sole trigger — probing shows the beta header - * alone passes through cleanly (`withAnthropicBetaHeaderFiltered` - * already drops the unknown beta from the allow-list anyway). The - * sibling `output_config.effort` field is Copilot's own reasoning-effort - * surface and must be preserved; only `format` is removed, and the - * container is dropped when it becomes empty so we don't ship a stray - * `output_config: {}`. - * - * Clients lose the grammar-constrained guarantee on this beta path, - * but the model still attends to the schema in-prompt and well-behaved - * callers re-parse with a schema validator (Claude Code's hook - * evaluator wraps the reply in Zod's `safeParse`, for example). - * - * References: - * - https://platform.claude.com/docs/en/build-with-claude/structured-outputs - * - https://github.com/anthropics/anthropic-sdk-typescript/blob/main/src/resources/messages/messages.ts (OutputConfig, JSONOutputFormat) - * - https://github.com/imbuxiangnan-cyber/copilot-api-plus/blob/0350e8805456b2c14e12358db66ae0584a5cc4ac/src/routes/messages/handler.ts#L260-L285 (prior art: transparent retry) - */ -export const withStructuredOutputFormatStripped: CopilotMessagesBoundaryInterceptor = async (ctx, _env, run) => { - const config = ctx.payload.output_config as Record | undefined; - if (config && 'format' in config) { - delete config.format; - if (Object.keys(config).length === 0) delete ctx.payload.output_config; - } - return await run(); -}; diff --git a/packages/provider-copilot/src/interceptors/messages/strip-tool-strict.ts b/packages/provider-copilot/src/interceptors/messages/strip-tool-strict.ts deleted file mode 100644 index 4a4297898..000000000 --- a/packages/provider-copilot/src/interceptors/messages/strip-tool-strict.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { CopilotMessagesBoundaryInterceptor } from './types.ts'; - -/** - * Anthropic Messages tools may carry `strict: true` to compile - * `input_schema` into a grammar (same pipeline as structured outputs). - * Copilot's Messages upstream is backed by Vertex AI Claude, whose - * organization policy `constraints/vertexai.allowedPartnerModelFeatures` - * denies `structured_outputs` by default — any tool with `strict: true` - * trips a 400 `FAILED_PRECONDITION` from Vertex. We drop the field on - * outbound; the model still respects `input_schema`, only the - * grammar-constrained guarantee is gone. - */ -export const withToolStrictStripped: CopilotMessagesBoundaryInterceptor = async (ctx, _env, run) => { - if (Array.isArray(ctx.payload.tools)) { - for (const tool of ctx.payload.tools as unknown as Record[]) { - if ('strict' in tool) delete tool.strict; - } - } - return await run(); -};