forked from boos/BoosAPI
Merge remote-tracking branch 'origin/main' into codex/codex-review-fixes
# Conflicts: # src/server/services/modelService.ts
This commit is contained in:
@@ -1058,6 +1058,168 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
||||
expect(String(fetchMock.mock.calls[7]?.[0] || '')).toContain('/projects/gen-lang-client-0123456789/services/cloudaicompanion.googleapis.com');
|
||||
});
|
||||
|
||||
it('surfaces Cloud AI API enable failures during Gemini CLI oauth setup and keeps the account rolled back', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
access_token: 'gemini-access-token',
|
||||
refresh_token: 'gemini-refresh-token',
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer',
|
||||
scope: 'cloud-platform',
|
||||
}),
|
||||
text: async () => JSON.stringify({ ok: true }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
projects: [
|
||||
{ projectId: 'project-enable-failure' },
|
||||
],
|
||||
}),
|
||||
text: async () => JSON.stringify({ ok: true }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
cloudaicompanionProject: {
|
||||
id: 'project-enable-failure',
|
||||
},
|
||||
allowedTiers: [
|
||||
{ id: 'legacy-tier', isDefault: true },
|
||||
],
|
||||
}),
|
||||
text: async () => JSON.stringify({ ok: true }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
done: true,
|
||||
response: {
|
||||
cloudaicompanionProject: {
|
||||
id: 'project-enable-failure',
|
||||
},
|
||||
},
|
||||
}),
|
||||
text: async () => JSON.stringify({ done: true }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ state: 'DISABLED' }),
|
||||
text: async () => JSON.stringify({ state: 'DISABLED' }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: async () => ({ error: { message: 'Permission denied for project-enable-failure' } }),
|
||||
text: async () => JSON.stringify({ error: { message: 'Permission denied for project-enable-failure' } }),
|
||||
});
|
||||
|
||||
const startResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/oauth/providers/gemini-cli/start',
|
||||
headers: {
|
||||
host: 'metapi.example',
|
||||
'x-forwarded-proto': 'https',
|
||||
},
|
||||
});
|
||||
expect(startResponse.statusCode).toBe(200);
|
||||
const startBody = startResponse.json() as { state: string };
|
||||
|
||||
const callbackResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`,
|
||||
payload: {
|
||||
callbackUrl: `http://localhost:8085/oauth2callback?state=${encodeURIComponent(startBody.state)}&code=gemini-oauth-code-enable-failure`,
|
||||
},
|
||||
});
|
||||
expect(callbackResponse.statusCode).toBe(500);
|
||||
expect(callbackResponse.json()).toMatchObject({
|
||||
message: expect.stringContaining('project activation required'),
|
||||
});
|
||||
|
||||
const sessionResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/oauth/sessions/${startBody.state}`,
|
||||
});
|
||||
expect(sessionResponse.statusCode).toBe(200);
|
||||
expect(sessionResponse.json()).toMatchObject({
|
||||
provider: 'gemini-cli',
|
||||
status: 'error',
|
||||
error: expect.stringContaining('Permission denied for project-enable-failure'),
|
||||
});
|
||||
|
||||
const accounts = await db.select().from(schema.accounts).all();
|
||||
expect(accounts).toEqual([]);
|
||||
});
|
||||
|
||||
it('fails Gemini CLI oauth setup when the Google account has no available Cloud projects', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
access_token: 'gemini-access-token',
|
||||
refresh_token: 'gemini-refresh-token',
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer',
|
||||
scope: 'cloud-platform',
|
||||
}),
|
||||
text: async () => JSON.stringify({ ok: true }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
projects: [],
|
||||
}),
|
||||
text: async () => JSON.stringify({ ok: true }),
|
||||
});
|
||||
|
||||
const startResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/oauth/providers/gemini-cli/start',
|
||||
headers: {
|
||||
host: 'metapi.example',
|
||||
'x-forwarded-proto': 'https',
|
||||
},
|
||||
});
|
||||
expect(startResponse.statusCode).toBe(200);
|
||||
const startBody = startResponse.json() as { state: string };
|
||||
|
||||
const callbackResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`,
|
||||
payload: {
|
||||
callbackUrl: `http://localhost:8085/oauth2callback?state=${encodeURIComponent(startBody.state)}&code=gemini-oauth-code-no-projects`,
|
||||
},
|
||||
});
|
||||
expect(callbackResponse.statusCode).toBe(500);
|
||||
expect(callbackResponse.json()).toMatchObject({
|
||||
message: 'no Google Cloud projects available for this account',
|
||||
});
|
||||
|
||||
const sessionResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/oauth/sessions/${startBody.state}`,
|
||||
});
|
||||
expect(sessionResponse.statusCode).toBe(200);
|
||||
expect(sessionResponse.json()).toMatchObject({
|
||||
provider: 'gemini-cli',
|
||||
status: 'error',
|
||||
error: 'no Google Cloud projects available for this account',
|
||||
});
|
||||
|
||||
const accounts = await db.select().from(schema.accounts).all();
|
||||
expect(accounts).toEqual([]);
|
||||
});
|
||||
|
||||
it('lists oauth connection health metadata and supports deleting the connection', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'ChatGPT Codex OAuth',
|
||||
|
||||
@@ -156,4 +156,44 @@ describe('claude count_tokens proxy route', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('supports /v1/messages/count_tokens for openai-platform gateways that expose Claude messages endpoints', async () => {
|
||||
selectChannelMock.mockReturnValue({
|
||||
channel: { id: 12, routeId: 23 },
|
||||
site: { name: 'gateway-site', url: 'https://gateway.example.com', platform: 'openai' },
|
||||
account: { id: 34, username: 'gateway-user@example.com' },
|
||||
tokenName: 'default',
|
||||
tokenValue: 'sk-gateway',
|
||||
actualModel: 'claude-sonnet-4-5-20250929',
|
||||
});
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
input_tokens: 9,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/messages/count_tokens',
|
||||
payload: {
|
||||
model: 'claude-sonnet-4-5-20250929',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'count through a compatible gateway' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ input_tokens: 9 });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
|
||||
expect(targetUrl).toBe('https://gateway.example.com/v1/messages/count_tokens?beta=true');
|
||||
expect(options.headers['x-api-key']).toBe('sk-gateway');
|
||||
expect(options.headers['anthropic-version']).toBe('2023-06-01');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { resetUpstreamEndpointRuntimeState } from './upstreamEndpoint.js';
|
||||
const fetchMock = vi.fn();
|
||||
const fetchModelPricingCatalogMock = vi.fn();
|
||||
const refreshModelsAndRebuildRoutesMock = vi.fn();
|
||||
const refreshOauthAccessTokenSingleflightMock = vi.fn();
|
||||
const selectChannelMock = vi.fn();
|
||||
const selectNextChannelMock = vi.fn();
|
||||
const recordSuccessMock = vi.fn();
|
||||
@@ -43,6 +44,7 @@ function createDbSelectChain() {
|
||||
|
||||
vi.mock('undici', () => ({
|
||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||
Response: globalThis.Response,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/modelPricingService.js', () => ({
|
||||
@@ -53,6 +55,10 @@ vi.mock('../../services/modelService.js', () => ({
|
||||
refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/oauth/refreshSingleflight.js', () => ({
|
||||
refreshOauthAccessTokenSingleflight: (...args: unknown[]) => refreshOauthAccessTokenSingleflightMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/tokenRouter.js', () => ({
|
||||
tokenRouter: {
|
||||
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
|
||||
@@ -154,6 +160,7 @@ describe('gemini native proxy routes', () => {
|
||||
fetchMock.mockReset();
|
||||
fetchModelPricingCatalogMock.mockReset();
|
||||
refreshModelsAndRebuildRoutesMock.mockReset();
|
||||
refreshOauthAccessTokenSingleflightMock.mockReset();
|
||||
selectChannelMock.mockReset();
|
||||
selectNextChannelMock.mockReset();
|
||||
recordSuccessMock.mockReset();
|
||||
@@ -514,6 +521,174 @@ describe('gemini native proxy routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshes gemini-cli oauth token and retries the same internal request on 401', async () => {
|
||||
selectChannelMock.mockReturnValue({
|
||||
channel: { id: 31, routeId: 22 },
|
||||
site: { id: 66, name: 'gemini-cli-site', url: 'https://cloudcode-pa.googleapis.com', platform: 'gemini-cli' },
|
||||
account: {
|
||||
id: 36,
|
||||
username: 'gemini-cli-user@example.com',
|
||||
accessToken: 'oauth-access-token',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: {
|
||||
provider: 'gemini-cli',
|
||||
email: 'gemini-cli-user@example.com',
|
||||
projectId: 'project-before-refresh',
|
||||
refreshToken: 'gemini-refresh-token',
|
||||
},
|
||||
}),
|
||||
},
|
||||
tokenName: 'default',
|
||||
tokenValue: 'oauth-access-token',
|
||||
actualModel: 'gemini-2.5-pro',
|
||||
});
|
||||
refreshOauthAccessTokenSingleflightMock.mockResolvedValue({
|
||||
accountId: 36,
|
||||
accessToken: 'refreshed-access-token',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: {
|
||||
provider: 'gemini-cli',
|
||||
email: 'gemini-cli-user@example.com',
|
||||
projectId: 'project-after-refresh',
|
||||
refreshToken: 'gemini-refresh-token',
|
||||
},
|
||||
}),
|
||||
});
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
error: { message: 'token expired' },
|
||||
}), {
|
||||
status: 401,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
response: {
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'hello after refresh' }],
|
||||
role: 'model',
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
},
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1beta/models/gemini-2.5-pro:generateContent',
|
||||
headers: {
|
||||
authorization: 'Bearer sk-managed-gemini',
|
||||
},
|
||||
payload: {
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(refreshOauthAccessTokenSingleflightMock).toHaveBeenCalledWith(36);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(selectNextChannelMock).not.toHaveBeenCalled();
|
||||
|
||||
const [, firstRequestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
const [, secondRequestInit] = fetchMock.mock.calls[1] as [string, RequestInit];
|
||||
expect(firstRequestInit.headers).toMatchObject({
|
||||
Authorization: 'Bearer oauth-access-token',
|
||||
});
|
||||
expect(secondRequestInit.headers).toMatchObject({
|
||||
Authorization: 'Bearer refreshed-access-token',
|
||||
});
|
||||
expect(JSON.parse(String(secondRequestInit.body))).toEqual({
|
||||
project: 'project-after-refresh',
|
||||
model: 'gemini-2.5-pro',
|
||||
request: {
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(response.json()).toEqual({
|
||||
responseId: '',
|
||||
modelVersion: '',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
content: {
|
||||
parts: [{ text: 'hello after refresh' }],
|
||||
role: 'model',
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a server error when gemini-cli oauth project metadata is missing at runtime', async () => {
|
||||
selectChannelMock.mockReturnValue({
|
||||
channel: { id: 31, routeId: 22 },
|
||||
site: { id: 66, name: 'gemini-cli-site', url: 'https://cloudcode-pa.googleapis.com', platform: 'gemini-cli' },
|
||||
account: {
|
||||
id: 36,
|
||||
username: 'gemini-cli-user@example.com',
|
||||
accessToken: 'oauth-access-token',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: {
|
||||
provider: 'gemini-cli',
|
||||
email: 'gemini-cli-user@example.com',
|
||||
},
|
||||
}),
|
||||
},
|
||||
tokenName: 'default',
|
||||
tokenValue: 'oauth-access-token',
|
||||
actualModel: 'gemini-2.5-pro',
|
||||
});
|
||||
selectNextChannelMock.mockReturnValue(null);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1beta/models/gemini-2.5-pro:generateContent',
|
||||
headers: {
|
||||
authorization: 'Bearer sk-managed-gemini',
|
||||
},
|
||||
payload: {
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(recordFailureMock).toHaveBeenCalledWith(31, {
|
||||
status: 500,
|
||||
errorText: 'Gemini CLI OAuth project is missing',
|
||||
});
|
||||
expect(JSON.parse(response.body)).toEqual({
|
||||
error: {
|
||||
message: 'Gemini CLI OAuth project is missing',
|
||||
type: 'server_error',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('routes Gemini native generateContent requests to openai upstreams and serializes the response back to Gemini shape', async () => {
|
||||
selectChannelMock.mockReturnValue({
|
||||
channel: { id: 41, routeId: 22 },
|
||||
@@ -602,6 +777,81 @@ describe('gemini native proxy routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes non-streaming generic upstream JSON into Gemini SSE when alt=sse is requested', async () => {
|
||||
selectChannelMock.mockReturnValue({
|
||||
channel: { id: 41, routeId: 22 },
|
||||
site: { id: 77, name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' },
|
||||
account: { id: 37, username: 'openai-user@example.com' },
|
||||
tokenName: 'default',
|
||||
tokenValue: 'openai-access-token',
|
||||
actualModel: 'gpt-4.1',
|
||||
});
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
id: 'chatcmpl-openai-stream-1',
|
||||
object: 'chat.completion',
|
||||
created: 1_742_160_003,
|
||||
model: 'gpt-4.1',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'hello from openai stream fallback',
|
||||
},
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 5,
|
||||
completion_tokens: 6,
|
||||
total_tokens: 11,
|
||||
},
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse',
|
||||
headers: {
|
||||
authorization: 'Bearer sk-managed-gemini',
|
||||
},
|
||||
payload: {
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||
expect(parseSsePayloads(response.body)).toEqual([
|
||||
{
|
||||
responseId: 'chatcmpl-openai-stream-1',
|
||||
modelVersion: 'gpt-4.1',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'hello from openai stream fallback' }],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 5,
|
||||
candidatesTokenCount: 6,
|
||||
totalTokenCount: 11,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('exposes GeminiCLI downstream generateContent endpoint and wraps the downstream response payload', async () => {
|
||||
selectChannelMock.mockReturnValue({
|
||||
channel: { id: 42, routeId: 22 },
|
||||
|
||||
@@ -196,6 +196,82 @@ describe('resolveUpstreamEndpointCandidates', () => {
|
||||
expect(order).toEqual(['responses', 'messages', 'chat']);
|
||||
});
|
||||
|
||||
it('does not apply runtime endpoint memory to image attachments', async () => {
|
||||
recordUpstreamEndpointSuccess({
|
||||
siteId: baseContext.site.id,
|
||||
endpoint: 'responses',
|
||||
downstreamFormat: 'openai',
|
||||
modelName: 'gpt-5.3',
|
||||
requestCapabilities: {
|
||||
conversationFileSummary: {
|
||||
hasImage: true,
|
||||
hasAudio: false,
|
||||
hasDocument: false,
|
||||
hasRemoteDocumentUrl: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const order = await resolveUpstreamEndpointCandidates(
|
||||
{
|
||||
...baseContext,
|
||||
site: { ...baseContext.site, platform: 'new-api' },
|
||||
},
|
||||
'gpt-5.3',
|
||||
'openai',
|
||||
undefined,
|
||||
{
|
||||
conversationFileSummary: {
|
||||
hasImage: true,
|
||||
hasAudio: false,
|
||||
hasDocument: false,
|
||||
hasRemoteDocumentUrl: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(order).toEqual(['chat', 'messages', 'responses']);
|
||||
});
|
||||
|
||||
it('does not apply runtime endpoint memory to document attachments', async () => {
|
||||
recordUpstreamEndpointSuccess({
|
||||
siteId: baseContext.site.id,
|
||||
endpoint: 'messages',
|
||||
downstreamFormat: 'openai',
|
||||
modelName: 'gpt-5.3',
|
||||
requestCapabilities: {
|
||||
hasNonImageFileInput: true,
|
||||
conversationFileSummary: {
|
||||
hasImage: false,
|
||||
hasAudio: false,
|
||||
hasDocument: true,
|
||||
hasRemoteDocumentUrl: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const order = await resolveUpstreamEndpointCandidates(
|
||||
{
|
||||
...baseContext,
|
||||
site: { ...baseContext.site, platform: 'new-api' },
|
||||
},
|
||||
'gpt-5.3',
|
||||
'openai',
|
||||
undefined,
|
||||
{
|
||||
hasNonImageFileInput: true,
|
||||
conversationFileSummary: {
|
||||
hasImage: false,
|
||||
hasAudio: false,
|
||||
hasDocument: true,
|
||||
hasRemoteDocumentUrl: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(order).toEqual(['responses', 'messages', 'chat']);
|
||||
});
|
||||
|
||||
it('remembers the last successful endpoint per site capability profile', async () => {
|
||||
recordUpstreamEndpointSuccess({
|
||||
siteId: baseContext.site.id,
|
||||
|
||||
@@ -41,6 +41,8 @@ export type EndpointPreference = DownstreamFormat | 'responses';
|
||||
|
||||
type EndpointCapabilityProfile = {
|
||||
preferMessagesForClaudeModel: boolean;
|
||||
hasImageInput: boolean;
|
||||
hasAudioInput: boolean;
|
||||
hasNonImageFileInput: boolean;
|
||||
hasRemoteDocumentUrl: boolean;
|
||||
wantsNativeResponsesReasoning: boolean;
|
||||
@@ -711,22 +713,35 @@ function buildEndpointCapabilityProfile(input?: {
|
||||
wantsNativeResponsesReasoning?: boolean;
|
||||
};
|
||||
}): EndpointCapabilityProfile {
|
||||
const conversationFileSummary = input?.requestCapabilities?.conversationFileSummary;
|
||||
return {
|
||||
preferMessagesForClaudeModel: (
|
||||
isClaudeFamilyModel(asTrimmedString(input?.modelName))
|
||||
|| isClaudeFamilyModel(asTrimmedString(input?.requestedModelHint))
|
||||
),
|
||||
hasImageInput: conversationFileSummary?.hasImage === true,
|
||||
hasAudioInput: conversationFileSummary?.hasAudio === true,
|
||||
hasNonImageFileInput: (
|
||||
input?.requestCapabilities?.conversationFileSummary?.hasDocument === true
|
||||
conversationFileSummary?.hasDocument === true
|
||||
|| input?.requestCapabilities?.hasNonImageFileInput === true
|
||||
),
|
||||
hasRemoteDocumentUrl: (
|
||||
input?.requestCapabilities?.conversationFileSummary?.hasRemoteDocumentUrl === true
|
||||
conversationFileSummary?.hasRemoteDocumentUrl === true
|
||||
),
|
||||
wantsNativeResponsesReasoning: input?.requestCapabilities?.wantsNativeResponsesReasoning === true,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldUseEndpointRuntimeMemory(capabilityProfile: EndpointCapabilityProfile): boolean {
|
||||
// Attachment-capable requests are not protocol-equivalent across chat/messages/responses.
|
||||
// A transient 200 on one endpoint should not bias later multimodal requests onto a lossy path.
|
||||
return (
|
||||
!capabilityProfile.hasImageInput
|
||||
&& !capabilityProfile.hasAudioInput
|
||||
&& !capabilityProfile.hasNonImageFileInput
|
||||
);
|
||||
}
|
||||
|
||||
function buildEndpointRuntimeStateKey(input: {
|
||||
siteId: number;
|
||||
downstreamFormat: EndpointPreference;
|
||||
@@ -866,17 +881,19 @@ export function recordUpstreamEndpointSuccess(input: {
|
||||
wantsNativeResponsesReasoning?: boolean;
|
||||
};
|
||||
}): void {
|
||||
const capabilityProfile = buildEndpointCapabilityProfile({
|
||||
modelName: input.modelName,
|
||||
requestedModelHint: input.requestedModelHint,
|
||||
requestCapabilities: input.requestCapabilities,
|
||||
});
|
||||
if (!shouldUseEndpointRuntimeMemory(capabilityProfile)) return;
|
||||
if (!shouldRememberSuccessfulEndpoint(input)) return;
|
||||
|
||||
const nowMs = Date.now();
|
||||
const key = buildEndpointRuntimeStateKey({
|
||||
siteId: input.siteId,
|
||||
downstreamFormat: input.downstreamFormat,
|
||||
capabilityProfile: buildEndpointCapabilityProfile({
|
||||
modelName: input.modelName,
|
||||
requestedModelHint: input.requestedModelHint,
|
||||
requestCapabilities: input.requestCapabilities,
|
||||
}),
|
||||
capabilityProfile,
|
||||
});
|
||||
const state = getOrCreateEndpointRuntimeState(key, nowMs);
|
||||
state.preferredEndpoint = input.endpoint;
|
||||
@@ -898,17 +915,19 @@ export function recordUpstreamEndpointFailure(input: {
|
||||
wantsNativeResponsesReasoning?: boolean;
|
||||
};
|
||||
}): void {
|
||||
const capabilityProfile = buildEndpointCapabilityProfile({
|
||||
modelName: input.modelName,
|
||||
requestedModelHint: input.requestedModelHint,
|
||||
requestCapabilities: input.requestCapabilities,
|
||||
});
|
||||
if (!shouldUseEndpointRuntimeMemory(capabilityProfile)) return;
|
||||
if (!shouldBlockEndpointByError(input.status, input.errorText)) return;
|
||||
|
||||
const nowMs = Date.now();
|
||||
const key = buildEndpointRuntimeStateKey({
|
||||
siteId: input.siteId,
|
||||
downstreamFormat: input.downstreamFormat,
|
||||
capabilityProfile: buildEndpointCapabilityProfile({
|
||||
modelName: input.modelName,
|
||||
requestedModelHint: input.requestedModelHint,
|
||||
requestCapabilities: input.requestCapabilities,
|
||||
}),
|
||||
capabilityProfile,
|
||||
});
|
||||
const state = getOrCreateEndpointRuntimeState(key, nowMs);
|
||||
state.blockedUntilMsByEndpoint[input.endpoint] = nowMs + ENDPOINT_RUNTIME_BLOCK_TTL_MS;
|
||||
@@ -1009,7 +1028,9 @@ export async function resolveUpstreamEndpointCandidates(
|
||||
capabilityProfile,
|
||||
});
|
||||
const applyRuntimePreference = (candidates: UpstreamEndpoint[]) => (
|
||||
applyEndpointRuntimePreference(candidates, runtimeStateKey)
|
||||
shouldUseEndpointRuntimeMemory(capabilityProfile)
|
||||
? applyEndpointRuntimePreference(candidates, runtimeStateKey)
|
||||
: candidates
|
||||
);
|
||||
const conversationFileSummary = requestCapabilities?.conversationFileSummary ?? {
|
||||
hasImage: false,
|
||||
@@ -1521,32 +1542,60 @@ export function buildClaudeCountTokensUpstreamRequest(input: {
|
||||
delete sanitizedBody.maxTokens;
|
||||
delete sanitizedBody.stream;
|
||||
const providerProfile = resolveProviderProfile(sitePlatform);
|
||||
if (providerProfile?.id !== 'claude') {
|
||||
throw new Error(`missing claude provider profile for platform: ${sitePlatform || 'unknown'}`);
|
||||
const effectiveClaudeHeaders = {
|
||||
...claudeHeaders,
|
||||
...(betas.length > 0 ? { 'anthropic-beta': betas.join(',') } : {}),
|
||||
};
|
||||
|
||||
if (providerProfile?.id === 'claude') {
|
||||
const prepared = providerProfile.prepareRequest({
|
||||
endpoint: 'messages',
|
||||
modelName: input.modelName,
|
||||
stream: false,
|
||||
tokenValue: input.tokenValue,
|
||||
oauthProvider: input.oauthProvider,
|
||||
sitePlatform,
|
||||
baseHeaders: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
claudeHeaders: effectiveClaudeHeaders,
|
||||
body: sanitizedBody,
|
||||
action: 'countTokens',
|
||||
});
|
||||
|
||||
return {
|
||||
path: prepared.path,
|
||||
headers: prepared.headers,
|
||||
body: prepared.body,
|
||||
runtime: {
|
||||
executor: 'claude',
|
||||
modelName: input.modelName,
|
||||
stream: false,
|
||||
action: 'countTokens',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const prepared = providerProfile.prepareRequest({
|
||||
endpoint: 'messages',
|
||||
modelName: input.modelName,
|
||||
stream: false,
|
||||
tokenValue: input.tokenValue,
|
||||
oauthProvider: input.oauthProvider,
|
||||
sitePlatform,
|
||||
const anthropicVersion = (
|
||||
effectiveClaudeHeaders['anthropic-version']
|
||||
|| '2023-06-01'
|
||||
);
|
||||
const isClaudeOauthUpstream = sitePlatform === 'claude' && input.oauthProvider === 'claude';
|
||||
const headers = buildClaudeRuntimeHeaders({
|
||||
baseHeaders: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
claudeHeaders: {
|
||||
...claudeHeaders,
|
||||
...(betas.length > 0 ? { 'anthropic-beta': betas.join(',') } : {}),
|
||||
},
|
||||
body: sanitizedBody,
|
||||
action: 'countTokens',
|
||||
claudeHeaders: effectiveClaudeHeaders,
|
||||
anthropicVersion,
|
||||
stream: false,
|
||||
isClaudeOauthUpstream,
|
||||
tokenValue: input.tokenValue,
|
||||
});
|
||||
|
||||
return {
|
||||
path: prepared.path,
|
||||
headers: prepared.headers,
|
||||
body: prepared.body,
|
||||
path: '/v1/messages/count_tokens?beta=true',
|
||||
headers,
|
||||
body: sanitizedBody,
|
||||
runtime: {
|
||||
executor: 'claude',
|
||||
modelName: input.modelName,
|
||||
|
||||
Reference in New Issue
Block a user