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(); -};