From 05cee28c182c7311b765f1aeb6d339be263cfc45 Mon Sep 17 00:00:00 2001 From: Steven Chong <25894545+teamchong@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:16:30 -0400 Subject: [PATCH] fix(proxy): gate Messages transforms by model family --- src/core/proxy.ts | 12 +++++-- tests/proxy-usage.test.ts | 71 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/src/core/proxy.ts b/src/core/proxy.ts index d2d7935..5553c9f 100644 --- a/src/core/proxy.ts +++ b/src/core/proxy.ts @@ -4,7 +4,7 @@ */ import { transformRequest, type TransformOptions, type TransformInfo } from './transform.js'; -import { transformOpenAIChatCompletions, transformOpenAIResponses } from './openai.js'; +import { isClaudeModel, transformOpenAIChatCompletions, transformOpenAIResponses } from './openai.js'; import { isAnthropicMessagesPath, isPxpipeSupportedGptModel, isPxpipeSupportedModel } from './applicability.js'; import { buildBaselineCountTokensBody, @@ -722,8 +722,14 @@ export function createProxy(config: ProxyConfig = {}) { // Fail-closed: unreadable model → no compression, not a risky guess. const model = readModelField(bodyIn); requestModel = model ?? undefined; + // /v1/messages is only a wire schema: Claude Code can target a non- + // Anthropic model (for example GPT-5.6 Sol). Do not apply Claude's + // renderer or Anthropic count_tokens merely because the route is + // Messages-shaped. Non-Anthropic Messages requests fail closed to + // passthrough until a model-aware Messages→Sol transform exists. + const messagesAnthropic = isMessages && isClaudeModel(model); const modelOk = isMessages - ? isPxpipeSupportedModel(model) + ? messagesAnthropic && isPxpipeSupportedModel(model) : isPxpipeSupportedGptModel(model); // Unsupported model → a true passthrough: no break-even compression // (a text-only model may not accept injected image blocks at all). @@ -743,7 +749,7 @@ export function createProxy(config: ProxyConfig = {}) { reqBodySha8 = await sha8Bytes(r.body); } - if (isMessages) { + if (isMessages && messagesAnthropic) { baselineStatusApplies = true; // Probes fire on the ORIGINAL body before the main forward so all three overlap. // count_tokens is not billed; ~30-80ms latency is hidden by the main forward. diff --git a/tests/proxy-usage.test.ts b/tests/proxy-usage.test.ts index fff7adb..74b0241 100644 --- a/tests/proxy-usage.test.ts +++ b/tests/proxy-usage.test.ts @@ -1,9 +1,7 @@ import { afterAll, beforeAll, describe, it, expect } from 'vitest'; import { createProxy, type ProxyEvent } from '../src/core/proxy.js'; -// These proxy-contract tests deliberately exercise the opt-in Sol transform. -// Snapshot the developer shell so the suite is deterministic now that Sol is -// intentionally absent from the built-in default scope. +// Pin the model scope so these proxy-contract tests stay independent of the developer shell. let ambientPxpipeModels: string | undefined; beforeAll(() => { ambientPxpipeModels = process.env.PXPIPE_MODELS; @@ -85,6 +83,35 @@ describe('proxy usage extraction', () => { expect(captured!.firstByteMs).toBeTypeOf('number'); }); + it('never calls Anthropic count_tokens for Sol Responses', async () => { + const upstreamRequests: Request[] = []; + const restore = mockUpstream(async (req) => { + upstreamRequests.push(req.clone()); + return new Response(JSON.stringify({ + id: 'resp_sol_1', object: 'response', status: 'completed', + output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'ok' }] }], + usage: { input_tokens: 400, output_tokens: 8, input_tokens_details: { cached_tokens: 300 } }, + }), { status: 200, headers: { 'content-type': 'application/json' } }); + }); + const proxy = createProxy({ + openAIUpstream: 'https://api.openai.test', openAIApiKey: 'sk-test', + transform: { charsPerToken: 1, minCompressChars: 1 }, + }); + const body = JSON.stringify({ + model: 'gpt-5.6-sol', + instructions: 'System instruction. '.repeat(900), + input: [{ role: 'user', content: 'hi' }], + }); + const res = await proxy(new Request('http://localhost/v1/responses', { + method: 'POST', headers: { 'content-type': 'application/json' }, body, + })); + await res.text(); + restore(); + expect(upstreamRequests).toHaveLength(1); + expect(upstreamRequests[0]!.url).toBe('https://api.openai.test/v1/responses'); + expect(upstreamRequests.some((r) => r.url.includes('/count_tokens'))).toBe(false); + }); + it('transforms OpenCode /anthropic/messages (no /v1) and records the model', async () => { const upstreamRequests: Request[] = []; const restore = mockUpstream(async (req) => { @@ -146,6 +173,41 @@ describe('proxy usage extraction', () => { ).toBe(true); }); + it('passes Claude Code Messages + Sol through without Anthropic count_tokens', async () => { + const upstreamRequests: Request[] = []; + const restore = mockUpstream(async (req) => { + upstreamRequests.push(req.clone()); + return new Response(JSON.stringify({ + id: 'msg_sol', type: 'message', role: 'assistant', + content: [{ type: 'text', text: 'ok' }], model: 'gpt-5.6-sol', + usage: { input_tokens: 120, output_tokens: 7 }, + }), { status: 200, headers: { 'content-type': 'application/json' } }); + }); + let captured: ProxyEvent | undefined; + const proxy = createProxy({ + upstream: 'http://ocproxy.test', apiKey: 'sk-test', + transform: { charsPerToken: 1, minCompressChars: 1 }, + onRequest: (e) => { captured = e; }, + }); + const body = JSON.stringify({ + model: 'gpt-5.6-sol', max_tokens: 16, + system: 'System instruction. '.repeat(900), + messages: [{ role: 'user', content: 'hi' }], + }); + const res = await proxy(new Request('http://localhost/v1/messages', { + method: 'POST', headers: { 'content-type': 'application/json' }, body, + })); + await res.text(); + await new Promise((r) => setTimeout(r, 20)); + restore(); + expect(upstreamRequests).toHaveLength(1); + expect(upstreamRequests[0]!.url).toBe('http://ocproxy.test/v1/messages'); + expect(upstreamRequests.some((r) => r.url.includes('/count_tokens'))).toBe(false); + expect(JSON.parse(await upstreamRequests[0]!.text())).toEqual(JSON.parse(body)); + expect(captured?.info?.compressed).toBe(false); + expect(captured?.info?.reason).toBe('unsupported_model'); + }); + it('routes GPT 5.6 Sol chat completions to OpenAI, transforms once, and normalizes usage', async () => { const upstreamRequests: Request[] = []; const restore = mockUpstream(async (req) => { @@ -198,7 +260,10 @@ describe('proxy usage extraction', () => { await new Promise((r) => setTimeout(r, 20)); restore(); + // OpenAI/Sol must never use Anthropic's /count_tokens endpoint. Its + // counterfactual is local o200k tokenization in src/core/openai.ts. expect(upstreamRequests).toHaveLength(1); + expect(upstreamRequests.some((r) => r.url.includes('/count_tokens'))).toBe(false); expect(upstreamRequests[0]!.url).toBe('https://api.openai.test/v1/chat/completions'); expect(upstreamRequests[0]!.headers.get('authorization')).toBe('Bearer sk-test'); const sent = JSON.parse(await upstreamRequests[0]!.text()) as any;