forked from boos/BoosAPI
Refresh the recent Docker tag PR onto current main
The release branch had fallen behind current main and only conflicted in the Update Center recent-tag test. Keep the latest main-side deploy-state coverage while preserving the PR's raw-tag filtering and digest visibility assertions. Constraint: PR #495 must merge into the current main without widening the PR diff beyond the update-center candidate behavior. Rejected: Drop the PR-specific digest/raw-tag assertions | would regress the review fixes that prompted the branch update. Confidence: high Scope-risk: narrow Directive: Keep recent Docker tag cards deployable only from real tagName values and keep digest metadata visible before deployment. Tested: npx vitest run --root . src/web/pages/settings/UpdateCenterSection.test.tsx src/server/services/updateCenterVersionService.test.ts src/server/routes/api/updateCenter.test.ts src/server/services/updateCenterStatusService.test.ts; npm run typecheck; npm run repo:drift-check; git diff --cached --check origin/main Not-tested: Full npm test.
This commit is contained in:
@@ -10,11 +10,6 @@
|
|||||||
汇聚成 <strong>一个 API Key、一个入口</strong>,自动发现模型、智能路由、成本最优。
|
汇聚成 <strong>一个 API Key、一个入口</strong>,自动发现模型、智能路由、成本最优。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://linux.do/t/topic/1671489" alt="LINUX DO">
|
|
||||||
<img src="https://shorturl.at/ggSqS" alt="LINUX DO">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/cita-777/metapi/releases">
|
<a href="https://github.com/cita-777/metapi/releases">
|
||||||
@@ -470,12 +465,9 @@ Metapi 完全自托管,所有数据(账号、令牌、路由、日志)均
|
|||||||
|
|
||||||
特别感谢所有贡献者:
|
特别感谢所有贡献者:
|
||||||
|
|
||||||
<!-- metapi-contributors:start -->
|
<a href="https://github.com/cita-777/metapi/graphs/contributors">
|
||||||
<p align="left">
|
<img src="https://contrib.rocks/image?repo=cita-777/metapi" alt="Contributors" />
|
||||||
<a href="https://github.com/cita-777"><img src="https://avatars.githubusercontent.com/u/177306803?v=4&s=48" width="48" height="48" alt="cita-777" title="cita-777"/></a> <a href="https://github.com/Hureru"><img src="https://avatars.githubusercontent.com/u/121702350?v=4&s=48" width="48" height="48" alt="Hureru" title="Hureru"/></a> <a href="https://github.com/bnvnvnv"><img src="https://avatars.githubusercontent.com/u/4243616?v=4&s=48" width="48" height="48" alt="bnvnvnv" title="bnvnvnv"/></a> <a href="https://github.com/ksmaze"><img src="https://avatars.githubusercontent.com/u/480916?v=4&s=48" width="48" height="48" alt="ksmaze" title="ksmaze"/></a> <a href="https://github.com/DeliciousBuding"><img src="https://avatars.githubusercontent.com/u/101502465?v=4&s=48" width="48" height="48" alt="DeliciousBuding" title="DeliciousBuding"/></a> <a href="https://github.com/Shinku-Chen"><img src="https://avatars.githubusercontent.com/u/17696928?v=4&s=48" width="48" height="48" alt="Shinku-Chen" title="Shinku-Chen"/></a> <a href="https://github.com/weijiafu14"><img src="https://avatars.githubusercontent.com/u/17469139?v=4&s=48" width="48" height="48" alt="weijiafu14" title="weijiafu14"/></a> <a href="https://github.com/ShicYang"><img src="https://avatars.githubusercontent.com/u/61652316?v=4&s=48" width="48" height="48" alt="ShicYang" title="ShicYang"/></a> <a href="https://github.com/Babylonehy"><img src="https://avatars.githubusercontent.com/u/30937892?v=4&s=48" width="48" height="48" alt="Babylonehy" title="Babylonehy"/></a> <a href="https://github.com/zmoon460"><img src="https://avatars.githubusercontent.com/u/42328751?v=4&s=48" width="48" height="48" alt="zmoon460" title="zmoon460"/></a>
|
</a>
|
||||||
<a href="https://github.com/Brucents"><img src="https://avatars.githubusercontent.com/u/81791987?v=4&s=48" width="48" height="48" alt="Brucents" title="Brucents"/></a> <a href="https://github.com/ImgBotApp"><img src="https://avatars.githubusercontent.com/u/31427850?v=4&s=48" width="48" height="48" alt="ImgBotApp" title="ImgBotApp"/></a> <a href="https://github.com/Zhou-Ruichen"><img src="https://avatars.githubusercontent.com/u/191002401?v=4&s=48" width="48" height="48" alt="Zhou-Ruichen" title="Zhou-Ruichen"/></a> <a href="https://github.com/nodca"><img src="https://avatars.githubusercontent.com/u/96775880?v=4&s=48" width="48" height="48" alt="nodca" title="nodca"/></a> <a href="https://github.com/puyujian"><img src="https://avatars.githubusercontent.com/u/46592377?v=4&s=48" width="48" height="48" alt="puyujian" title="puyujian"/></a> <a href="https://github.com/rcocco"><img src="https://avatars.githubusercontent.com/u/46603462?v=4&s=48" width="48" height="48" alt="rcocco" title="rcocco"/></a> <a href="https://github.com/xuyufengfei"><img src="https://avatars.githubusercontent.com/u/188047874?v=4&s=48" width="48" height="48" alt="xuyufengfei" title="xuyufengfei"/></a>
|
|
||||||
</p>
|
|
||||||
<!-- metapi-contributors:end -->
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
|
|||||||
proxyDebugTargetModel: (env.PROXY_DEBUG_TARGET_MODEL || '').trim(),
|
proxyDebugTargetModel: (env.PROXY_DEBUG_TARGET_MODEL || '').trim(),
|
||||||
proxyDebugRetentionHours: Math.max(1, Math.trunc(parseNumber(env.PROXY_DEBUG_RETENTION_HOURS, 24))),
|
proxyDebugRetentionHours: Math.max(1, Math.trunc(parseNumber(env.PROXY_DEBUG_RETENTION_HOURS, 24))),
|
||||||
proxyDebugMaxBodyBytes: Math.max(1024, Math.trunc(parseNumber(env.PROXY_DEBUG_MAX_BODY_BYTES, 262_144))),
|
proxyDebugMaxBodyBytes: Math.max(1024, Math.trunc(parseNumber(env.PROXY_DEBUG_MAX_BODY_BYTES, 262_144))),
|
||||||
|
openAiServiceTierRules: parseJsonValue(env.OPENAI_SERVICE_TIER_RULES_JSON || env.OPENAI_SERVICE_TIER_RULES),
|
||||||
modelAvailabilityProbeEnabled: parseBoolean(env.MODEL_AVAILABILITY_PROBE_ENABLED, false),
|
modelAvailabilityProbeEnabled: parseBoolean(env.MODEL_AVAILABILITY_PROBE_ENABLED, false),
|
||||||
modelAvailabilityProbeIntervalMs: Math.max(60_000, Math.trunc(parseNumber(env.MODEL_AVAILABILITY_PROBE_INTERVAL_MS, 30 * 60 * 1000))),
|
modelAvailabilityProbeIntervalMs: Math.max(60_000, Math.trunc(parseNumber(env.MODEL_AVAILABILITY_PROBE_INTERVAL_MS, 30 * 60 * 1000))),
|
||||||
modelAvailabilityProbeTimeoutMs: Math.max(3_000, Math.trunc(parseNumber(env.MODEL_AVAILABILITY_PROBE_TIMEOUT_MS, 15_000))),
|
modelAvailabilityProbeTimeoutMs: Math.max(3_000, Math.trunc(parseNumber(env.MODEL_AVAILABILITY_PROBE_TIMEOUT_MS, 15_000))),
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
hasResponsesWebSearchOnlyRequest,
|
||||||
|
validateExternalResponsesHttpRequest,
|
||||||
|
} from './responsesPreflight.js';
|
||||||
|
|
||||||
|
describe('validateExternalResponsesHttpRequest', () => {
|
||||||
|
it('rejects external HTTP previous_response_id and explains msg ids separately', () => {
|
||||||
|
const responseIdResult = validateExternalResponsesHttpRequest({
|
||||||
|
model: 'gpt-5',
|
||||||
|
previous_response_id: 'resp_prev_1',
|
||||||
|
input: 'hello',
|
||||||
|
});
|
||||||
|
expect(responseIdResult.ok).toBe(false);
|
||||||
|
if (!responseIdResult.ok) {
|
||||||
|
expect(responseIdResult.payload.error.message).toContain('HTTP /v1/responses does not support');
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageIdResult = validateExternalResponsesHttpRequest({
|
||||||
|
model: 'gpt-5',
|
||||||
|
previous_response_id: 'msg_prev_1',
|
||||||
|
input: 'hello',
|
||||||
|
});
|
||||||
|
expect(messageIdResult.ok).toBe(false);
|
||||||
|
if (!messageIdResult.ok) {
|
||||||
|
expect(messageIdResult.payload.error.message).toContain('response id');
|
||||||
|
expect(messageIdResult.payload.error.message).toContain('msg_*');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects HTTP function_call_output without call_id or local context, but allows matching item_reference', () => {
|
||||||
|
const missingCallId = validateExternalResponsesHttpRequest({
|
||||||
|
model: 'gpt-5',
|
||||||
|
input: [{ type: 'function_call_output', output: 'done' }],
|
||||||
|
});
|
||||||
|
expect(missingCallId.ok).toBe(false);
|
||||||
|
if (!missingCallId.ok) {
|
||||||
|
expect(missingCallId.payload.error.message).toContain('requires call_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingContext = validateExternalResponsesHttpRequest({
|
||||||
|
model: 'gpt-5',
|
||||||
|
input: [{ type: 'function_call_output', call_id: 'call_1', output: 'done' }],
|
||||||
|
});
|
||||||
|
expect(missingContext.ok).toBe(false);
|
||||||
|
if (!missingContext.ok) {
|
||||||
|
expect(missingContext.payload.error.message).toContain('Responses WebSocket v2');
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(validateExternalResponsesHttpRequest({
|
||||||
|
model: 'gpt-5',
|
||||||
|
input: [
|
||||||
|
{ type: 'function_call', id: 'fc_1', call_id: 'call_1', name: 'lookup', arguments: '{}' },
|
||||||
|
{ type: 'function_call_output', call_id: 'call_1', output: 'done' },
|
||||||
|
],
|
||||||
|
})).toEqual({ ok: true });
|
||||||
|
|
||||||
|
expect(validateExternalResponsesHttpRequest({
|
||||||
|
model: 'gpt-5',
|
||||||
|
input: [
|
||||||
|
{ type: 'function_call', id: 'fc_1', call_id: 'call_other', name: 'lookup', arguments: '{}' },
|
||||||
|
{ type: 'function_call_output', call_id: 'call_1', item_reference: 'fc_1', output: 'done' },
|
||||||
|
],
|
||||||
|
})).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasResponsesWebSearchOnlyRequest', () => {
|
||||||
|
it('recognizes the current web_search_preview tool alias', () => {
|
||||||
|
expect(hasResponsesWebSearchOnlyRequest({
|
||||||
|
model: 'gpt-5',
|
||||||
|
tools: [{ type: 'web_search_preview_2025_03_11' }],
|
||||||
|
input: 'metapi protocol compatibility',
|
||||||
|
})).toBe(true);
|
||||||
|
|
||||||
|
expect(hasResponsesWebSearchOnlyRequest({
|
||||||
|
model: 'gpt-5',
|
||||||
|
tools: [
|
||||||
|
{ type: 'web_search_preview_2025_03_11' },
|
||||||
|
{ type: 'function', name: 'lookup' },
|
||||||
|
],
|
||||||
|
input: 'metapi protocol compatibility',
|
||||||
|
})).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
export type ResponsesPreflightResult =
|
||||||
|
| { ok: true }
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
statusCode: 400;
|
||||||
|
payload: {
|
||||||
|
error: {
|
||||||
|
message: string;
|
||||||
|
type: 'invalid_request_error';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asTrimmedString(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidRequest(message: string): Extract<ResponsesPreflightResult, { ok: false }> {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
statusCode: 400,
|
||||||
|
payload: {
|
||||||
|
error: {
|
||||||
|
message,
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkRecords(value: unknown, visitor: (item: Record<string, unknown>) => void): void {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) walkRecords(item, visitor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isRecord(value)) return;
|
||||||
|
visitor(value);
|
||||||
|
for (const item of Object.values(value)) {
|
||||||
|
if (Array.isArray(item) || isRecord(item)) walkRecords(item, visitor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateExternalPreviousResponseId(body: Record<string, unknown>): ResponsesPreflightResult {
|
||||||
|
const previousResponseId = asTrimmedString(body.previous_response_id);
|
||||||
|
if (!previousResponseId) return { ok: true };
|
||||||
|
|
||||||
|
const message = previousResponseId.startsWith('msg_')
|
||||||
|
? 'previous_response_id must be a response id beginning with resp_; message ids like msg_* are not valid, and HTTP /v1/responses does not support continuation chaining. Use Responses WebSocket v2 for function-call follow-up turns.'
|
||||||
|
: 'HTTP /v1/responses does not support user-supplied previous_response_id continuation chaining. Use Responses WebSocket v2 for follow-up turns.';
|
||||||
|
return invalidRequest(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateFunctionCallOutputs(
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
options: { allowContinuationToolOutput?: boolean } = {},
|
||||||
|
): ResponsesPreflightResult {
|
||||||
|
const input = body.input;
|
||||||
|
if (!Array.isArray(input)) return { ok: true };
|
||||||
|
|
||||||
|
const knownCallIds = new Set<string>();
|
||||||
|
const knownReferenceIds = new Set<string>();
|
||||||
|
const outputs: Record<string, unknown>[] = [];
|
||||||
|
|
||||||
|
for (const item of input) {
|
||||||
|
if (!isRecord(item)) continue;
|
||||||
|
const itemType = asTrimmedString(item.type).toLowerCase();
|
||||||
|
if (itemType === 'function_call' || itemType === 'custom_tool_call' || itemType === 'tool_call') {
|
||||||
|
const callId = asTrimmedString(item.call_id ?? item.id);
|
||||||
|
if (callId) knownCallIds.add(callId);
|
||||||
|
const id = asTrimmedString(item.id);
|
||||||
|
if (id) knownReferenceIds.add(id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (itemType === 'function_call_output' || itemType === 'custom_tool_call_output') {
|
||||||
|
outputs.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.allowContinuationToolOutput) {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of outputs) {
|
||||||
|
const callId = asTrimmedString(item.call_id);
|
||||||
|
if (!callId) {
|
||||||
|
return invalidRequest('function_call_output requires call_id. Use Responses WebSocket v2 for incremental tool-output follow-up turns.');
|
||||||
|
}
|
||||||
|
const itemReference = asTrimmedString(
|
||||||
|
item.item_reference
|
||||||
|
?? item.itemReference
|
||||||
|
?? (isRecord(item.reference) ? item.reference.id : undefined),
|
||||||
|
);
|
||||||
|
if (!knownCallIds.has(callId) && (!itemReference || !knownReferenceIds.has(itemReference))) {
|
||||||
|
return invalidRequest('function_call_output must match a function_call/tool_call in the same HTTP request or include a matching item_reference. Use Responses WebSocket v2 for continuation turns.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateExternalResponsesHttpRequest(
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
options: { allowContinuationToolOutput?: boolean } = {},
|
||||||
|
): ResponsesPreflightResult {
|
||||||
|
const previousResponseResult = validateExternalPreviousResponseId(body);
|
||||||
|
if (!previousResponseResult.ok) return previousResponseResult;
|
||||||
|
|
||||||
|
const functionCallOutputResult = validateFunctionCallOutputs(body, options);
|
||||||
|
if (!functionCallOutputResult.ok) return functionCallOutputResult;
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWebSearchToolRecord(tool: Record<string, unknown>): boolean {
|
||||||
|
const type = asTrimmedString(tool.type).toLowerCase();
|
||||||
|
const name = asTrimmedString(tool.name).toLowerCase();
|
||||||
|
return type === 'web_search'
|
||||||
|
|| type === 'web_search_preview'
|
||||||
|
|| type === 'web_search_preview_2025_03_11'
|
||||||
|
|| type === 'web_search_20250305'
|
||||||
|
|| type === 'google_search'
|
||||||
|
|| name === 'web_search'
|
||||||
|
|| name === 'google_search';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasResponsesWebSearchOnlyRequest(body: Record<string, unknown>): boolean {
|
||||||
|
let hasWebSearchTool = false;
|
||||||
|
let hasOtherTools = false;
|
||||||
|
|
||||||
|
const tools = Array.isArray(body.tools) ? body.tools : [];
|
||||||
|
for (const tool of tools) {
|
||||||
|
if (!isRecord(tool)) continue;
|
||||||
|
if (isWebSearchToolRecord(tool)) {
|
||||||
|
hasWebSearchTool = true;
|
||||||
|
} else {
|
||||||
|
hasOtherTools = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasWebSearchTool && !hasOtherTools;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractResponsesWebSearchQuery(body: Record<string, unknown>): string {
|
||||||
|
if (typeof body.input === 'string') {
|
||||||
|
const query = body.input.trim();
|
||||||
|
if (query) return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryCandidates: string[] = [];
|
||||||
|
walkRecords(body.input, (item) => {
|
||||||
|
const type = asTrimmedString(item.type).toLowerCase();
|
||||||
|
if (type === 'input_text' || type === 'text') {
|
||||||
|
const text = asTrimmedString(item.text);
|
||||||
|
if (text) queryCandidates.push(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === 'message') {
|
||||||
|
const text = asTrimmedString(item.content);
|
||||||
|
if (text) queryCandidates.push(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return queryCandidates[queryCandidates.length - 1] || '';
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { applyOpenAiServiceTierPolicy } from './serviceTierPolicy.js';
|
||||||
|
|
||||||
|
describe('applyOpenAiServiceTierPolicy', () => {
|
||||||
|
it('normalizes fast to priority, leaves absent tiers alone, and drops unknown or non-string tiers', () => {
|
||||||
|
expect(applyOpenAiServiceTierPolicy({
|
||||||
|
body: { model: 'gpt-5' },
|
||||||
|
})).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
body: { model: 'gpt-5' },
|
||||||
|
action: 'pass',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(applyOpenAiServiceTierPolicy({
|
||||||
|
body: { model: 'gpt-5', service_tier: ' fast ' },
|
||||||
|
})).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
body: { model: 'gpt-5', service_tier: 'priority' },
|
||||||
|
action: 'pass',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(applyOpenAiServiceTierPolicy({
|
||||||
|
body: { model: 'gpt-5', service_tier: 'turbo' },
|
||||||
|
})).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
body: { model: 'gpt-5' },
|
||||||
|
action: 'filter',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(applyOpenAiServiceTierPolicy({
|
||||||
|
body: { model: 'gpt-5', service_tier: 123 },
|
||||||
|
})).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
body: { model: 'gpt-5' },
|
||||||
|
action: 'filter',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies pass, filter and block rules by tier, model, platform and account type', () => {
|
||||||
|
const rules = [
|
||||||
|
{
|
||||||
|
action: 'filter',
|
||||||
|
tiers: ['flex'],
|
||||||
|
platforms: ['sub2api'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: 'block',
|
||||||
|
tiers: ['priority'],
|
||||||
|
models: ['gpt-5'],
|
||||||
|
platforms: ['openai'],
|
||||||
|
accountTypes: ['free'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(applyOpenAiServiceTierPolicy({
|
||||||
|
body: { model: 'gpt-5', service_tier: 'flex' },
|
||||||
|
context: { sitePlatform: 'sub2api', requestedModel: 'gpt-5' },
|
||||||
|
rules,
|
||||||
|
})).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
body: { model: 'gpt-5' },
|
||||||
|
serviceTier: 'flex',
|
||||||
|
action: 'filter',
|
||||||
|
});
|
||||||
|
|
||||||
|
const blocked = applyOpenAiServiceTierPolicy({
|
||||||
|
body: { model: 'gpt-5', service_tier: 'fast' },
|
||||||
|
context: {
|
||||||
|
sitePlatform: 'openai',
|
||||||
|
requestedModel: 'gpt-5',
|
||||||
|
accountType: 'free',
|
||||||
|
},
|
||||||
|
rules,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(blocked.ok).toBe(false);
|
||||||
|
if (!blocked.ok) {
|
||||||
|
expect(blocked.statusCode).toBe(400);
|
||||||
|
expect(blocked.payload.error.message).toContain('priority');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
export type ServiceTierAction = 'pass' | 'filter' | 'block';
|
||||||
|
|
||||||
|
export type ServiceTierRule = {
|
||||||
|
action: ServiceTierAction;
|
||||||
|
tiers?: string[];
|
||||||
|
models?: string[];
|
||||||
|
platforms?: string[];
|
||||||
|
accountTypes?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceTierRuleContext = {
|
||||||
|
requestedModel?: string | null;
|
||||||
|
actualModel?: string | null;
|
||||||
|
sitePlatform?: string | null;
|
||||||
|
accountType?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceTierPolicyResult =
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
body: Record<string, unknown>;
|
||||||
|
serviceTier?: string;
|
||||||
|
action: 'pass' | 'filter';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
statusCode: 400;
|
||||||
|
payload: {
|
||||||
|
error: {
|
||||||
|
message: string;
|
||||||
|
type: 'invalid_request_error';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const KNOWN_OPENAI_SERVICE_TIERS = new Set([
|
||||||
|
'auto',
|
||||||
|
'default',
|
||||||
|
'flex',
|
||||||
|
'priority',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asTrimmedString(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMatchList(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value
|
||||||
|
.map((item) => asTrimmedString(item).toLowerCase())
|
||||||
|
.filter((item) => item.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRule(value: unknown): ServiceTierRule | null {
|
||||||
|
if (!isRecord(value)) return null;
|
||||||
|
const action = asTrimmedString(value.action).toLowerCase();
|
||||||
|
if (action !== 'pass' && action !== 'filter' && action !== 'block') return null;
|
||||||
|
return {
|
||||||
|
action,
|
||||||
|
tiers: normalizeMatchList(value.tiers),
|
||||||
|
models: normalizeMatchList(value.models),
|
||||||
|
platforms: normalizeMatchList(value.platforms),
|
||||||
|
accountTypes: normalizeMatchList(value.accountTypes ?? value.account_types),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRules(value: unknown): ServiceTierRule[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value
|
||||||
|
.map((item) => normalizeRule(item))
|
||||||
|
.filter((item): item is ServiceTierRule => !!item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function candidateMatches(patterns: string[] | undefined, candidates: Array<string | null | undefined>): boolean {
|
||||||
|
if (!patterns || patterns.length <= 0) return true;
|
||||||
|
const normalizedCandidates = candidates
|
||||||
|
.map((item) => asTrimmedString(item).toLowerCase())
|
||||||
|
.filter((item) => item.length > 0);
|
||||||
|
if (normalizedCandidates.length <= 0) return false;
|
||||||
|
|
||||||
|
return patterns.some((pattern) => {
|
||||||
|
if (pattern === '*') return true;
|
||||||
|
return normalizedCandidates.some((candidate) => candidate === pattern);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ruleMatches(
|
||||||
|
rule: ServiceTierRule,
|
||||||
|
serviceTier: string,
|
||||||
|
context: ServiceTierRuleContext,
|
||||||
|
): boolean {
|
||||||
|
return candidateMatches(rule.tiers, [serviceTier])
|
||||||
|
&& candidateMatches(rule.models, [context.actualModel, context.requestedModel])
|
||||||
|
&& candidateMatches(rule.platforms, [context.sitePlatform])
|
||||||
|
&& candidateMatches(rule.accountTypes, [context.accountType]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServiceTier(raw: unknown): string | null {
|
||||||
|
if (typeof raw !== 'string') return null;
|
||||||
|
const normalized = raw.trim().toLowerCase();
|
||||||
|
if (!normalized) return null;
|
||||||
|
if (normalized === 'fast') return 'priority';
|
||||||
|
if (!KNOWN_OPENAI_SERVICE_TIERS.has(normalized)) return null;
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBlockedResult(serviceTier: string): Extract<ServiceTierPolicyResult, { ok: false }> {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
statusCode: 400,
|
||||||
|
payload: {
|
||||||
|
error: {
|
||||||
|
message: `service_tier '${serviceTier}' is not allowed for this upstream policy`,
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyOpenAiServiceTierPolicy(input: {
|
||||||
|
body: Record<string, unknown>;
|
||||||
|
context?: ServiceTierRuleContext;
|
||||||
|
rules?: unknown;
|
||||||
|
}): ServiceTierPolicyResult {
|
||||||
|
const hasRequestedServiceTier = Object.prototype.hasOwnProperty.call(input.body, 'service_tier');
|
||||||
|
const rawTier = input.body.service_tier;
|
||||||
|
const serviceTier = normalizeServiceTier(rawTier);
|
||||||
|
const next = { ...input.body };
|
||||||
|
|
||||||
|
if (!serviceTier) {
|
||||||
|
delete next.service_tier;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
body: next,
|
||||||
|
action: hasRequestedServiceTier ? 'filter' : 'pass',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
next.service_tier = serviceTier;
|
||||||
|
const rules = normalizeRules(input.rules);
|
||||||
|
const matchedRule = rules.find((rule) => ruleMatches(rule, serviceTier, input.context || {}));
|
||||||
|
if (matchedRule?.action === 'block') return buildBlockedResult(serviceTier);
|
||||||
|
if (matchedRule?.action === 'filter') {
|
||||||
|
delete next.service_tier;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
body: next,
|
||||||
|
serviceTier,
|
||||||
|
action: 'filter',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
body: next,
|
||||||
|
serviceTier,
|
||||||
|
action: 'pass',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -52,6 +52,8 @@ import { getRuntimeResponseReader, readRuntimeResponseText } from '../executors/
|
|||||||
import { detectDownstreamClientContext } from '../downstreamClientContext.js';
|
import { detectDownstreamClientContext } from '../downstreamClientContext.js';
|
||||||
import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
||||||
import { shouldAbortSameSiteEndpointFallback } from '../../services/proxyRetryPolicy.js';
|
import { shouldAbortSameSiteEndpointFallback } from '../../services/proxyRetryPolicy.js';
|
||||||
|
import { applyOpenAiServiceTierPolicy } from '../serviceTierPolicy.js';
|
||||||
|
import { maybeHandleWebSearchOnlySimulation } from '../webSearchSimulation.js';
|
||||||
import {
|
import {
|
||||||
acquireSurfaceChannelLease,
|
acquireSurfaceChannelLease,
|
||||||
bindSurfaceStickyChannel,
|
bindSurfaceStickyChannel,
|
||||||
@@ -144,6 +146,17 @@ export async function handleChatSurfaceRequest(
|
|||||||
upstreamBody,
|
upstreamBody,
|
||||||
claudeOriginalBody,
|
claudeOriginalBody,
|
||||||
} = requestEnvelope.parsed;
|
} = requestEnvelope.parsed;
|
||||||
|
if (downstreamFormat === 'claude') {
|
||||||
|
const handledSearch = await maybeHandleWebSearchOnlySimulation({
|
||||||
|
app: request.server,
|
||||||
|
request,
|
||||||
|
reply,
|
||||||
|
downstreamFormat: 'claude',
|
||||||
|
body: (claudeOriginalBody || request.body || {}) as Record<string, unknown>,
|
||||||
|
openAiBody: upstreamBody,
|
||||||
|
});
|
||||||
|
if (handledSearch) return;
|
||||||
|
}
|
||||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||||
const forcedChannelId = getTesterForcedChannelId({
|
const forcedChannelId = getTesterForcedChannelId({
|
||||||
@@ -328,6 +341,29 @@ export async function handleChatSurfaceRequest(
|
|||||||
options: { forceNormalizeClaudeBody?: boolean } = {},
|
options: { forceNormalizeClaudeBody?: boolean } = {},
|
||||||
) => {
|
) => {
|
||||||
const upstreamStream = isStream || (forceResponsesUpstreamStream && endpoint === 'responses');
|
const upstreamStream = isStream || (forceResponsesUpstreamStream && endpoint === 'responses');
|
||||||
|
const bodyForEndpoint = endpoint === 'responses'
|
||||||
|
? (() => {
|
||||||
|
const policyResult = applyOpenAiServiceTierPolicy({
|
||||||
|
body: resolvedOpenAiBody,
|
||||||
|
context: {
|
||||||
|
requestedModel,
|
||||||
|
actualModel: modelName,
|
||||||
|
sitePlatform: selected.site.platform,
|
||||||
|
accountType: oauth?.planType,
|
||||||
|
},
|
||||||
|
rules: (config as any).openAiServiceTierRules,
|
||||||
|
});
|
||||||
|
if (!policyResult.ok) {
|
||||||
|
const error = new SiteApiEndpointRequestError(policyResult.payload.error.message, {
|
||||||
|
status: policyResult.statusCode,
|
||||||
|
rawErrText: JSON.stringify(policyResult.payload),
|
||||||
|
});
|
||||||
|
(error as SiteApiEndpointRequestError & { serviceTierBlocked?: boolean }).serviceTierBlocked = true;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return policyResult.body;
|
||||||
|
})()
|
||||||
|
: resolvedOpenAiBody;
|
||||||
const endpointRequest = buildUpstreamEndpointRequest({
|
const endpointRequest = buildUpstreamEndpointRequest({
|
||||||
endpoint,
|
endpoint,
|
||||||
modelName,
|
modelName,
|
||||||
@@ -337,7 +373,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
oauthProjectId: oauth?.projectId,
|
oauthProjectId: oauth?.projectId,
|
||||||
sitePlatform: selected.site.platform,
|
sitePlatform: selected.site.platform,
|
||||||
siteUrl: siteApiBaseUrl,
|
siteUrl: siteApiBaseUrl,
|
||||||
openaiBody: resolvedOpenAiBody,
|
openaiBody: bodyForEndpoint,
|
||||||
downstreamFormat,
|
downstreamFormat,
|
||||||
claudeOriginalBody,
|
claudeOriginalBody,
|
||||||
forceNormalizeClaudeBody: options.forceNormalizeClaudeBody,
|
forceNormalizeClaudeBody: options.forceNormalizeClaudeBody,
|
||||||
@@ -959,8 +995,24 @@ export async function handleChatSurfaceRequest(
|
|||||||
err instanceof SiteApiEndpointRequestError
|
err instanceof SiteApiEndpointRequestError
|
||||||
|| err?.name === 'SiteApiEndpointRequestError'
|
|| err?.name === 'SiteApiEndpointRequestError'
|
||||||
|| err?.siteApiEndpointUpstreamFailure === true
|
|| err?.siteApiEndpointUpstreamFailure === true
|
||||||
|
|| err?.serviceTierBlocked === true
|
||||||
|| (endpointFailureStatus !== null && endpointFailureStatus >= 500)
|
|| (endpointFailureStatus !== null && endpointFailureStatus >= 500)
|
||||||
);
|
);
|
||||||
|
if (err?.serviceTierBlocked === true) {
|
||||||
|
let payload: unknown = null;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(err.rawErrText || '');
|
||||||
|
} catch {
|
||||||
|
payload = {
|
||||||
|
error: {
|
||||||
|
message: err.message || 'service_tier is blocked by policy',
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await finalizeDebugFailure(endpointFailureStatus || 400, payload, null);
|
||||||
|
return reply.code(endpointFailureStatus || 400).send(payload);
|
||||||
|
}
|
||||||
if (isSiteApiEndpointFailure) {
|
if (isSiteApiEndpointFailure) {
|
||||||
const failureOutcome = await failureToolkit.handleUpstreamFailure({
|
const failureOutcome = await failureToolkit.handleUpstreamFailure({
|
||||||
selected,
|
selected,
|
||||||
@@ -1391,8 +1443,24 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
|||||||
error instanceof SiteApiEndpointRequestError
|
error instanceof SiteApiEndpointRequestError
|
||||||
|| error?.name === 'SiteApiEndpointRequestError'
|
|| error?.name === 'SiteApiEndpointRequestError'
|
||||||
|| error?.siteApiEndpointUpstreamFailure === true
|
|| error?.siteApiEndpointUpstreamFailure === true
|
||||||
|
|| error?.serviceTierBlocked === true
|
||||||
|| (endpointFailureStatus !== null && endpointFailureStatus >= 500)
|
|| (endpointFailureStatus !== null && endpointFailureStatus >= 500)
|
||||||
);
|
);
|
||||||
|
if (error?.serviceTierBlocked === true) {
|
||||||
|
let payload: unknown = null;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(error.rawErrText || '');
|
||||||
|
} catch {
|
||||||
|
payload = {
|
||||||
|
error: {
|
||||||
|
message: error.message || 'service_tier is blocked by policy',
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await finalizeDebugFailure(endpointFailureStatus || 400, payload, null);
|
||||||
|
return reply.code(endpointFailureStatus || 400).send(payload);
|
||||||
|
}
|
||||||
if (isSiteApiEndpointFailure) {
|
if (isSiteApiEndpointFailure) {
|
||||||
const failureOutcome = await failureToolkit.handleUpstreamFailure({
|
const failureOutcome = await failureToolkit.handleUpstreamFailure({
|
||||||
selected,
|
selected,
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ import {
|
|||||||
shouldFallbackCompactResponsesToResponses,
|
shouldFallbackCompactResponsesToResponses,
|
||||||
} from '../capabilities/responsesCompact.js';
|
} from '../capabilities/responsesCompact.js';
|
||||||
import { detectDownstreamClientContext } from '../downstreamClientContext.js';
|
import { detectDownstreamClientContext } from '../downstreamClientContext.js';
|
||||||
|
import { validateExternalResponsesHttpRequest } from '../responsesPreflight.js';
|
||||||
|
import { applyOpenAiServiceTierPolicy } from '../serviceTierPolicy.js';
|
||||||
|
import { maybeHandleWebSearchOnlySimulation } from '../webSearchSimulation.js';
|
||||||
import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
||||||
import { shouldAbortSameSiteEndpointFallback } from '../../services/proxyRetryPolicy.js';
|
import { shouldAbortSameSiteEndpointFallback } from '../../services/proxyRetryPolicy.js';
|
||||||
import {
|
import {
|
||||||
@@ -262,6 +265,14 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
const defaultEncryptedReasoningInclude = isCodexResponsesSurface(
|
const defaultEncryptedReasoningInclude = isCodexResponsesSurface(
|
||||||
request.headers as Record<string, unknown>,
|
request.headers as Record<string, unknown>,
|
||||||
);
|
);
|
||||||
|
if (!isResponsesWebsocketTransportRequest(request.headers as Record<string, unknown>)) {
|
||||||
|
const preflight = validateExternalResponsesHttpRequest(body, {
|
||||||
|
allowContinuationToolOutput: defaultEncryptedReasoningInclude,
|
||||||
|
});
|
||||||
|
if (!preflight.ok) {
|
||||||
|
return reply.code(preflight.statusCode).send(preflight.payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
const parsedRequestEnvelope = openAiResponsesTransformer.transformRequest(body, {
|
const parsedRequestEnvelope = openAiResponsesTransformer.transformRequest(body, {
|
||||||
defaultEncryptedReasoningInclude,
|
defaultEncryptedReasoningInclude,
|
||||||
});
|
});
|
||||||
@@ -280,6 +291,16 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (!isCompactRequest) {
|
||||||
|
const handledSearch = await maybeHandleWebSearchOnlySimulation({
|
||||||
|
app: request.server,
|
||||||
|
request,
|
||||||
|
reply,
|
||||||
|
downstreamFormat: 'responses',
|
||||||
|
body: requestEnvelope.parsed.normalizedBody,
|
||||||
|
});
|
||||||
|
if (handledSearch) return;
|
||||||
|
}
|
||||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||||
const forcedChannelId = getTesterForcedChannelId({
|
const forcedChannelId = getTesterForcedChannelId({
|
||||||
@@ -400,6 +421,21 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
model: modelName,
|
model: modelName,
|
||||||
stream: isStream,
|
stream: isStream,
|
||||||
};
|
};
|
||||||
|
const serviceTierPolicy = applyOpenAiServiceTierPolicy({
|
||||||
|
body: normalizedResponsesBody,
|
||||||
|
context: {
|
||||||
|
requestedModel,
|
||||||
|
actualModel: modelName,
|
||||||
|
sitePlatform: selected.site.platform,
|
||||||
|
accountType: oauth?.planType,
|
||||||
|
},
|
||||||
|
rules: (config as any).openAiServiceTierRules,
|
||||||
|
});
|
||||||
|
if (!serviceTierPolicy.ok) {
|
||||||
|
await finalizeDebugFailure(serviceTierPolicy.statusCode, serviceTierPolicy.payload, null);
|
||||||
|
return reply.code(serviceTierPolicy.statusCode).send(serviceTierPolicy.payload);
|
||||||
|
}
|
||||||
|
normalizedResponsesBody = serviceTierPolicy.body;
|
||||||
if (body.generate === false) {
|
if (body.generate === false) {
|
||||||
normalizedResponsesBody.generate = false;
|
normalizedResponsesBody.generate = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { anthropicMessagesTransformer } from '../transformers/anthropic/messages/index.js';
|
||||||
|
import {
|
||||||
|
extractResponsesWebSearchQuery,
|
||||||
|
hasResponsesWebSearchOnlyRequest,
|
||||||
|
} from './responsesPreflight.js';
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asTrimmedString(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEARCH_SIMULATION_MODEL = '__search';
|
||||||
|
|
||||||
|
function findSearchTool(body: Record<string, unknown>): Record<string, unknown> | null {
|
||||||
|
const tools = Array.isArray(body.tools) ? body.tools : [];
|
||||||
|
for (const tool of tools) {
|
||||||
|
if (!isRecord(tool)) continue;
|
||||||
|
const type = asTrimmedString(tool.type).toLowerCase();
|
||||||
|
const name = asTrimmedString(tool.name).toLowerCase();
|
||||||
|
if (
|
||||||
|
type === 'web_search'
|
||||||
|
|| type === 'web_search_preview'
|
||||||
|
|| type === 'web_search_preview_2025_03_11'
|
||||||
|
|| type === 'web_search_20250305'
|
||||||
|
|| type === 'google_search'
|
||||||
|
|| name === 'web_search'
|
||||||
|
|| name === 'google_search'
|
||||||
|
) {
|
||||||
|
return tool;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSearchMaxResults(tool: Record<string, unknown> | null): number {
|
||||||
|
const raw = tool?.max_uses ?? tool?.max_results ?? tool?.maxResults;
|
||||||
|
if (typeof raw !== 'number' || !Number.isFinite(raw)) return 10;
|
||||||
|
return Math.max(1, Math.min(20, Math.trunc(raw)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSearchInjectHeaders(request: FastifyRequest): Record<string, string | string[]> {
|
||||||
|
const headers: Record<string, string | string[]> = {};
|
||||||
|
for (const [rawKey, rawValue] of Object.entries(request.headers as Record<string, string | string[]>)) {
|
||||||
|
const key = rawKey.toLowerCase();
|
||||||
|
if (
|
||||||
|
key === 'host'
|
||||||
|
|| key === 'content-length'
|
||||||
|
|| key === 'content-type'
|
||||||
|
|| key === 'connection'
|
||||||
|
|| key === 'transfer-encoding'
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rawValue === undefined) continue;
|
||||||
|
headers[rawKey] = rawValue;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAnthropicSearchQuery(body: Record<string, unknown>): string {
|
||||||
|
const messages = Array.isArray(body.messages) ? body.messages : [];
|
||||||
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||||
|
const message = messages[index];
|
||||||
|
if (!isRecord(message)) continue;
|
||||||
|
if (asTrimmedString(message.role).toLowerCase() !== 'user') continue;
|
||||||
|
const content = message.content;
|
||||||
|
if (typeof content === 'string' && content.trim()) return content.trim();
|
||||||
|
if (!Array.isArray(content)) continue;
|
||||||
|
const parts = content
|
||||||
|
.map((item) => {
|
||||||
|
if (typeof item === 'string') return item.trim();
|
||||||
|
if (!isRecord(item)) return '';
|
||||||
|
const type = asTrimmedString(item.type).toLowerCase();
|
||||||
|
if (type && type !== 'text' && type !== 'input_text') return '';
|
||||||
|
return asTrimmedString(item.text ?? item.content);
|
||||||
|
})
|
||||||
|
.filter((item) => item.length > 0);
|
||||||
|
if (parts.length > 0) return parts.join('\n');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callLocalSearchRoute(input: {
|
||||||
|
app: FastifyInstance;
|
||||||
|
request: FastifyRequest;
|
||||||
|
query: string;
|
||||||
|
model: string;
|
||||||
|
maxResults: number;
|
||||||
|
}): Promise<{ statusCode: number; payload: unknown }> {
|
||||||
|
const searchResponse = await input.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/search',
|
||||||
|
headers: buildSearchInjectHeaders(input.request),
|
||||||
|
payload: {
|
||||||
|
model: input.model,
|
||||||
|
query: input.query,
|
||||||
|
max_results: input.maxResults,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let payload: unknown = null;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(searchResponse.body);
|
||||||
|
} catch {
|
||||||
|
payload = searchResponse.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode: searchResponse.statusCode,
|
||||||
|
payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSearchResults(payload: unknown): unknown[] {
|
||||||
|
if (!isRecord(payload)) return [];
|
||||||
|
const data = Array.isArray(payload.data) ? payload.data : [];
|
||||||
|
const results = Array.isArray(payload.results) ? payload.results : [];
|
||||||
|
return data.length > 0 ? data : results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSyntheticResponsesPayload(input: {
|
||||||
|
body: Record<string, unknown>;
|
||||||
|
query: string;
|
||||||
|
searchPayload: unknown;
|
||||||
|
}) {
|
||||||
|
const createdAt = Math.floor(Date.now() / 1000);
|
||||||
|
const responseId = `resp_web_search_${randomUUID()}`;
|
||||||
|
const searchCallId = `ws_${randomUUID()}`;
|
||||||
|
const results = normalizeSearchResults(input.searchPayload);
|
||||||
|
const outputText = results.length > 0 ? JSON.stringify(results) : '[]';
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: responseId,
|
||||||
|
object: 'response',
|
||||||
|
created_at: createdAt,
|
||||||
|
model: asTrimmedString(input.body.model) || 'unknown',
|
||||||
|
status: 'completed',
|
||||||
|
output_text: outputText,
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
id: searchCallId,
|
||||||
|
type: 'web_search_call',
|
||||||
|
status: 'completed',
|
||||||
|
action: {
|
||||||
|
type: 'search',
|
||||||
|
query: input.query,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `msg_${searchCallId}`,
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'completed',
|
||||||
|
content: [{
|
||||||
|
type: 'output_text',
|
||||||
|
text: outputText,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: {
|
||||||
|
input_tokens: 0,
|
||||||
|
output_tokens: 0,
|
||||||
|
total_tokens: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeResponsesSse(payload: Record<string, unknown>): string[] {
|
||||||
|
return [
|
||||||
|
`event: response.completed\ndata: ${JSON.stringify({
|
||||||
|
type: 'response.completed',
|
||||||
|
response: payload,
|
||||||
|
})}\n\n`,
|
||||||
|
'data: [DONE]\n\n',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendAnthropicSearchSimulation(input: {
|
||||||
|
app: FastifyInstance;
|
||||||
|
request: FastifyRequest;
|
||||||
|
reply: FastifyReply;
|
||||||
|
body: Record<string, unknown>;
|
||||||
|
openAiBody: Record<string, unknown>;
|
||||||
|
searchBody: Record<string, unknown>;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const tool = findSearchTool(input.searchBody) || findSearchTool(input.openAiBody);
|
||||||
|
const query = extractAnthropicSearchQuery(input.body) || extractResponsesWebSearchQuery(input.openAiBody);
|
||||||
|
if (!query) return false;
|
||||||
|
|
||||||
|
const search = await callLocalSearchRoute({
|
||||||
|
app: input.app,
|
||||||
|
request: input.request,
|
||||||
|
query,
|
||||||
|
model: SEARCH_SIMULATION_MODEL,
|
||||||
|
maxResults: toSearchMaxResults(tool),
|
||||||
|
});
|
||||||
|
if (search.statusCode < 200 || search.statusCode >= 300) {
|
||||||
|
input.reply.code(search.statusCode).send(search.payload);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const responsesPayload = buildSyntheticResponsesPayload({
|
||||||
|
body: input.openAiBody,
|
||||||
|
query,
|
||||||
|
searchPayload: search.payload,
|
||||||
|
});
|
||||||
|
if (input.body.stream === true) {
|
||||||
|
const streamContext = anthropicMessagesTransformer.createStreamContext(asTrimmedString(input.body.model) || 'unknown');
|
||||||
|
const claudeContext = anthropicMessagesTransformer.createDownstreamContext();
|
||||||
|
const lines = anthropicMessagesTransformer.serializeUpstreamFinalAsStream(
|
||||||
|
responsesPayload,
|
||||||
|
asTrimmedString(input.body.model) || 'unknown',
|
||||||
|
'',
|
||||||
|
streamContext,
|
||||||
|
claudeContext,
|
||||||
|
);
|
||||||
|
input.reply
|
||||||
|
.code(200)
|
||||||
|
.header('Content-Type', 'text/event-stream; charset=utf-8')
|
||||||
|
.header('Cache-Control', 'no-cache, no-transform')
|
||||||
|
.send(lines.join(''));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = anthropicMessagesTransformer.transformFinalResponse(
|
||||||
|
responsesPayload,
|
||||||
|
asTrimmedString(input.body.model) || 'unknown',
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
input.reply.code(200).send(anthropicMessagesTransformer.serializeFinalResponse(normalized, {
|
||||||
|
promptTokens: 0,
|
||||||
|
completionTokens: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendResponsesSearchSimulation(input: {
|
||||||
|
app: FastifyInstance;
|
||||||
|
request: FastifyRequest;
|
||||||
|
reply: FastifyReply;
|
||||||
|
body: Record<string, unknown>;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
if (!hasResponsesWebSearchOnlyRequest(input.body)) return false;
|
||||||
|
const tool = findSearchTool(input.body);
|
||||||
|
const query = extractResponsesWebSearchQuery(input.body);
|
||||||
|
if (!query) return false;
|
||||||
|
|
||||||
|
const search = await callLocalSearchRoute({
|
||||||
|
app: input.app,
|
||||||
|
request: input.request,
|
||||||
|
query,
|
||||||
|
model: SEARCH_SIMULATION_MODEL,
|
||||||
|
maxResults: toSearchMaxResults(tool),
|
||||||
|
});
|
||||||
|
if (search.statusCode < 200 || search.statusCode >= 300) {
|
||||||
|
input.reply.code(search.statusCode).send(search.payload);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildSyntheticResponsesPayload({
|
||||||
|
body: input.body,
|
||||||
|
query,
|
||||||
|
searchPayload: search.payload,
|
||||||
|
});
|
||||||
|
if (input.body.stream === true) {
|
||||||
|
input.reply
|
||||||
|
.code(200)
|
||||||
|
.header('Content-Type', 'text/event-stream; charset=utf-8')
|
||||||
|
.header('Cache-Control', 'no-cache, no-transform')
|
||||||
|
.send(serializeResponsesSse(payload).join(''));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.reply.code(200).send(payload);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function maybeHandleWebSearchOnlySimulation(input: {
|
||||||
|
app: FastifyInstance;
|
||||||
|
request: FastifyRequest;
|
||||||
|
reply: FastifyReply;
|
||||||
|
downstreamFormat: 'responses' | 'claude';
|
||||||
|
body: Record<string, unknown>;
|
||||||
|
openAiBody?: Record<string, unknown>;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
if (input.downstreamFormat === 'responses') {
|
||||||
|
return sendResponsesSearchSimulation({
|
||||||
|
app: input.app,
|
||||||
|
request: input.request,
|
||||||
|
reply: input.reply,
|
||||||
|
body: input.body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAiBody = input.openAiBody;
|
||||||
|
const rawBodyHasSearchOnly = hasResponsesWebSearchOnlyRequest(input.body);
|
||||||
|
const openAiBodyHasSearchOnly = !!openAiBody && hasResponsesWebSearchOnlyRequest(openAiBody);
|
||||||
|
if (!openAiBody || (!rawBodyHasSearchOnly && !openAiBodyHasSearchOnly)) return false;
|
||||||
|
return sendAnthropicSearchSimulation({
|
||||||
|
app: input.app,
|
||||||
|
request: input.request,
|
||||||
|
reply: input.reply,
|
||||||
|
body: input.body,
|
||||||
|
openAiBody,
|
||||||
|
searchBody: rawBodyHasSearchOnly ? input.body : openAiBody,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isResponsesWebSearchOnlyRequest(body: Record<string, unknown>): boolean {
|
||||||
|
return hasResponsesWebSearchOnlyRequest(body);
|
||||||
|
}
|
||||||
@@ -81,6 +81,8 @@ describe('settings and auth events', () => {
|
|||||||
(config as any).telegramChatId = '';
|
(config as any).telegramChatId = '';
|
||||||
(config as any).telegramUseSystemProxy = false;
|
(config as any).telegramUseSystemProxy = false;
|
||||||
(config as any).telegramMessageThreadId = '';
|
(config as any).telegramMessageThreadId = '';
|
||||||
|
config.globalBlockedBrands = [];
|
||||||
|
config.globalAllowedModels = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -745,6 +747,32 @@ describe('settings and auth events', () => {
|
|||||||
expect(runtime.proxyEmptyContentFailEnabled).toBe(true);
|
expect(runtime.proxyEmptyContentFailEnabled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('persists global model and brand filters as JSON arrays', async () => {
|
||||||
|
const updateResponse = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/settings/runtime',
|
||||||
|
payload: {
|
||||||
|
globalAllowedModels: ['model-alpha', ' model-beta ', 'model-alpha', 'model-gamma'],
|
||||||
|
globalBlockedBrands: ['Codex', ' Codex ', 'Gemini'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updateResponse.statusCode).toBe(200);
|
||||||
|
const updated = updateResponse.json() as {
|
||||||
|
globalAllowedModels?: string[];
|
||||||
|
globalBlockedBrands?: string[];
|
||||||
|
};
|
||||||
|
expect(updated.globalAllowedModels).toEqual(['model-alpha', 'model-beta', 'model-gamma']);
|
||||||
|
expect(updated.globalBlockedBrands).toEqual(['Codex', 'Gemini']);
|
||||||
|
|
||||||
|
const rows = await db.select().from(schema.settings).all();
|
||||||
|
const settingsMap = new Map(rows.map((row) => [row.key, row.value]));
|
||||||
|
expect(settingsMap.get('global_allowed_models')).toBe(JSON.stringify(['model-alpha', 'model-beta', 'model-gamma']));
|
||||||
|
expect(JSON.parse(settingsMap.get('global_allowed_models') || 'null')).toEqual(['model-alpha', 'model-beta', 'model-gamma']);
|
||||||
|
expect(settingsMap.get('global_blocked_brands')).toBe(JSON.stringify(['Codex', 'Gemini']));
|
||||||
|
expect(JSON.parse(settingsMap.get('global_blocked_brands') || 'null')).toEqual(['Codex', 'Gemini']);
|
||||||
|
});
|
||||||
|
|
||||||
it('persists and returns log cleanup settings from runtime settings', async () => {
|
it('persists and returns log cleanup settings from runtime settings', async () => {
|
||||||
const updateResponse = await app.inject({
|
const updateResponse = await app.inject({
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
@@ -1425,7 +1425,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
changedLabels.push('全局品牌屏蔽');
|
changedLabels.push('全局品牌屏蔽');
|
||||||
}
|
}
|
||||||
config.globalBlockedBrands = uniqueBrands;
|
config.globalBlockedBrands = uniqueBrands;
|
||||||
upsertSetting('global_blocked_brands', JSON.stringify(uniqueBrands));
|
upsertSetting('global_blocked_brands', uniqueBrands);
|
||||||
if (prev !== next) {
|
if (prev !== next) {
|
||||||
startBackgroundTask(
|
startBackgroundTask(
|
||||||
{
|
{
|
||||||
@@ -1450,7 +1450,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
changedLabels.push('全局模型白名单');
|
changedLabels.push('全局模型白名单');
|
||||||
}
|
}
|
||||||
config.globalAllowedModels = uniqueModels;
|
config.globalAllowedModels = uniqueModels;
|
||||||
upsertSetting('global_allowed_models', JSON.stringify(uniqueModels));
|
upsertSetting('global_allowed_models', uniqueModels);
|
||||||
if (prev !== next) {
|
if (prev !== next) {
|
||||||
startBackgroundTask(
|
startBackgroundTask(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -112,10 +112,12 @@ describe('chat proxy stream behavior', () => {
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const { chatProxyRoute, claudeMessagesProxyRoute } = await import('./chat.js');
|
const { chatProxyRoute, claudeMessagesProxyRoute } = await import('./chat.js');
|
||||||
const { responsesProxyRoute } = await import('./responses.js');
|
const { responsesProxyRoute } = await import('./responses.js');
|
||||||
|
const { searchProxyRoute } = await import('./search.js');
|
||||||
app = Fastify();
|
app = Fastify();
|
||||||
await app.register(chatProxyRoute);
|
await app.register(chatProxyRoute);
|
||||||
await app.register(claudeMessagesProxyRoute);
|
await app.register(claudeMessagesProxyRoute);
|
||||||
await app.register(responsesProxyRoute);
|
await app.register(responsesProxyRoute);
|
||||||
|
await app.register(searchProxyRoute);
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -156,6 +158,7 @@ describe('chat proxy stream behavior', () => {
|
|||||||
filter: [],
|
filter: [],
|
||||||
};
|
};
|
||||||
(config as any).disableCrossProtocolFallback = false;
|
(config as any).disableCrossProtocolFallback = false;
|
||||||
|
(config as any).openAiServiceTierRules = undefined;
|
||||||
config.proxyEmptyContentFailEnabled = false;
|
config.proxyEmptyContentFailEnabled = false;
|
||||||
config.proxyErrorKeywords = [];
|
config.proxyErrorKeywords = [];
|
||||||
});
|
});
|
||||||
@@ -193,7 +196,7 @@ describe('chat proxy stream behavior', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode, response.body).toBe(200);
|
||||||
expect(response.headers['content-type']).toContain('text/event-stream');
|
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||||
expect(response.body).toContain('data: ');
|
expect(response.body).toContain('data: ');
|
||||||
expect(response.body).toContain('"chat.completion.chunk"');
|
expect(response.body).toContain('"chat.completion.chunk"');
|
||||||
@@ -233,7 +236,7 @@ describe('chat proxy stream behavior', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode, response.body).toBe(200);
|
||||||
expect(response.json()?.choices?.[0]?.message?.content).toBe('你好,来自 zstd 非流式响应');
|
expect(response.json()?.choices?.[0]?.message?.content).toBe('你好,来自 zstd 非流式响应');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -268,7 +271,7 @@ describe('chat proxy stream behavior', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode, response.body).toBe(200);
|
||||||
expect(response.headers['content-type']).toContain('text/event-stream');
|
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||||
expect(response.body).toContain('"chat.completion.chunk"');
|
expect(response.body).toContain('"chat.completion.chunk"');
|
||||||
expect(response.body).toContain('你好,来自 zstd 流式回退');
|
expect(response.body).toContain('你好,来自 zstd 流式回退');
|
||||||
@@ -300,7 +303,7 @@ describe('chat proxy stream behavior', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode, response.body).toBe(200);
|
||||||
expect(response.headers['content-type']).toContain('text/event-stream');
|
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||||
expect(response.body).toContain('"chat.completion.chunk"');
|
expect(response.body).toContain('"chat.completion.chunk"');
|
||||||
expect(response.body).toContain('你好,来自 zstd 原生 SSE');
|
expect(response.body).toContain('你好,来自 zstd 原生 SSE');
|
||||||
@@ -513,7 +516,7 @@ describe('chat proxy stream behavior', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode, response.body).toBe(200);
|
||||||
expect(response.headers['content-type']).toContain('text/event-stream');
|
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||||
expect(response.body).toContain('visible answer despite zero output usage');
|
expect(response.body).toContain('visible answer despite zero output usage');
|
||||||
expect(response.body).toContain('data: [DONE]');
|
expect(response.body).toContain('data: [DONE]');
|
||||||
@@ -563,7 +566,7 @@ describe('chat proxy stream behavior', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode, response.body).toBe(200);
|
||||||
expect(response.headers['content-type']).toContain('text/event-stream');
|
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||||
expect(response.headers['cache-control']).toContain('no-transform');
|
expect(response.headers['cache-control']).toContain('no-transform');
|
||||||
expect(response.headers['x-accel-buffering']).toBe('no');
|
expect(response.headers['x-accel-buffering']).toBe('no');
|
||||||
@@ -600,7 +603,7 @@ describe('chat proxy stream behavior', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode, response.body).toBe(200);
|
||||||
expect(response.body).toContain('"reasoning_content":"plan quietly"');
|
expect(response.body).toContain('"reasoning_content":"plan quietly"');
|
||||||
expect(response.body).toContain('"content":"visible answer"');
|
expect(response.body).toContain('"content":"visible answer"');
|
||||||
expect(response.body).not.toContain('<think>');
|
expect(response.body).not.toContain('<think>');
|
||||||
@@ -639,7 +642,7 @@ describe('chat proxy stream behavior', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode, response.body).toBe(200);
|
||||||
expect(response.body).toContain('"reasoning_content":"plan "');
|
expect(response.body).toContain('"reasoning_content":"plan "');
|
||||||
expect(response.body).toContain('"reasoning_content":"quietly"');
|
expect(response.body).toContain('"reasoning_content":"quietly"');
|
||||||
expect(response.body).toContain('"content":"visible "');
|
expect(response.body).toContain('"content":"visible "');
|
||||||
@@ -710,7 +713,7 @@ describe('chat proxy stream behavior', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode, response.body).toBe(200);
|
||||||
expect(response.headers['content-type']).toContain('text/event-stream');
|
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||||
expect(response.body).toContain('"chat.completion.chunk"');
|
expect(response.body).toContain('"chat.completion.chunk"');
|
||||||
expect(response.body).toContain('"delta":{"content":"hello"}');
|
expect(response.body).toContain('"delta":{"content":"hello"}');
|
||||||
@@ -777,7 +780,7 @@ describe('chat proxy stream behavior', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode, response.body).toBe(200);
|
||||||
const body = response.json();
|
const body = response.json();
|
||||||
expect(body.type).toBe('message');
|
expect(body.type).toBe('message');
|
||||||
expect(body.role).toBe('assistant');
|
expect(body.role).toBe('assistant');
|
||||||
@@ -815,7 +818,7 @@ describe('chat proxy stream behavior', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode, response.body).toBe(200);
|
||||||
expect(response.headers['content-type']).toContain('text/event-stream');
|
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||||
expect(response.body).toContain('event: message_start');
|
expect(response.body).toContain('event: message_start');
|
||||||
expect(response.body).toContain('event: content_block_delta');
|
expect(response.body).toContain('event: content_block_delta');
|
||||||
@@ -2018,7 +2021,7 @@ describe('chat proxy stream behavior', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves sub2api responses semantics across compatibility retries without using a strict field-dropping body', async () => {
|
it('rejects external HTTP previous_response_id before sub2api compatibility retries', async () => {
|
||||||
selectChannelMock.mockReturnValue({
|
selectChannelMock.mockReturnValue({
|
||||||
channel: { id: 11, routeId: 22 },
|
channel: { id: 11, routeId: 22 },
|
||||||
site: { name: 'sub2api-site', url: 'https://sub2api.example.com', platform: 'sub2api' },
|
site: { name: 'sub2api-site', url: 'https://sub2api.example.com', platform: 'sub2api' },
|
||||||
@@ -2076,48 +2079,9 @@ describe('chat proxy stream behavior', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(400);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
expect(response.json().error.message).toContain('HTTP /v1/responses');
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
const [, firstOptions] = fetchMock.mock.calls[0] as [string, any];
|
|
||||||
const [, secondOptions] = fetchMock.mock.calls[1] as [string, any];
|
|
||||||
const firstBody = JSON.parse(firstOptions.body);
|
|
||||||
const secondBody = JSON.parse(secondOptions.body);
|
|
||||||
|
|
||||||
expect(firstOptions.headers.accept).toBe('text/event-stream');
|
|
||||||
expect(secondOptions.headers.accept).toBe('text/event-stream');
|
|
||||||
expect(firstOptions.headers['openai-beta']).toBe('responses-2025-03-11');
|
|
||||||
expect(secondOptions.headers['openai-beta']).toBe('responses-2025-03-11');
|
|
||||||
expect(firstOptions.headers.originator).toBe('codex_cli_rs');
|
|
||||||
expect(secondOptions.headers.originator).toBe('codex_cli_rs');
|
|
||||||
expect(secondOptions.headers['accept-language']).toBe('zh-CN');
|
|
||||||
expect(secondOptions.headers.session_id).toBe('session-123');
|
|
||||||
expect(secondOptions.headers.conversation_id).toBe('conversation-123');
|
|
||||||
expect(secondOptions.headers['x-codex-turn-state']).toBe('turn-state');
|
|
||||||
expect(secondOptions.headers['x-codex-turn-metadata']).toBe('turn-metadata');
|
|
||||||
expect(secondOptions.headers['x-stainless-lang']).toBeUndefined();
|
|
||||||
expect(secondOptions.headers.version).toBeUndefined();
|
|
||||||
expect(secondOptions.headers['user-agent']).toBe('lightMyRequest');
|
|
||||||
expect(firstBody).toMatchObject({
|
|
||||||
stream: true,
|
|
||||||
store: false,
|
|
||||||
previous_response_id: 'resp_prev_1',
|
|
||||||
include: ['reasoning.encrypted_content'],
|
|
||||||
reasoning: { effort: 'high' },
|
|
||||||
prompt_cache_key: 'cache-key-1',
|
|
||||||
service_tier: 'priority',
|
|
||||||
background: true,
|
|
||||||
});
|
|
||||||
expect(secondBody).toMatchObject({
|
|
||||||
stream: true,
|
|
||||||
store: false,
|
|
||||||
previous_response_id: 'resp_prev_1',
|
|
||||||
include: ['reasoning.encrypted_content'],
|
|
||||||
reasoning: { effort: 'high' },
|
|
||||||
prompt_cache_key: 'cache-key-1',
|
|
||||||
service_tier: 'priority',
|
|
||||||
background: true,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retries generic 400 /v1/responses with minimal headers for strict compatibility fallback', async () => {
|
it('retries generic 400 /v1/responses with minimal headers for strict compatibility fallback', async () => {
|
||||||
@@ -2863,7 +2827,7 @@ describe('chat proxy stream behavior', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode, response.body).toBe(200);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
const [targetUrl] = fetchMock.mock.calls[0] as [string, any];
|
const [targetUrl] = fetchMock.mock.calls[0] as [string, any];
|
||||||
expect(targetUrl).toContain('/v1/responses');
|
expect(targetUrl).toContain('/v1/responses');
|
||||||
@@ -4697,6 +4661,184 @@ describe('chat proxy stream behavior', () => {
|
|||||||
expect(forwardedBody.tool_choice).toEqual({ type: 'function', name: 'Glob' });
|
expect(forwardedBody.tool_choice).toEqual({ type: 'function', name: 'Glob' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('forwards legacy functions/function_call when /v1/chat/completions is routed to /v1/responses upstream', async () => {
|
||||||
|
fetchModelPricingCatalogMock.mockResolvedValue({
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
modelName: 'upstream-gpt',
|
||||||
|
supportedEndpointTypes: ['/v1/responses'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
groupRatio: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
id: 'resp_legacy_chat',
|
||||||
|
object: 'response',
|
||||||
|
model: 'upstream-gpt',
|
||||||
|
status: 'completed',
|
||||||
|
output: [],
|
||||||
|
output_text: 'ok',
|
||||||
|
usage: { input_tokens: 3, output_tokens: 2, total_tokens: 5 },
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/chat/completions',
|
||||||
|
payload: {
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
stream: false,
|
||||||
|
functions: [{
|
||||||
|
name: 'legacy_lookup',
|
||||||
|
parameters: { type: 'object' },
|
||||||
|
}],
|
||||||
|
function_call: { name: 'legacy_lookup' },
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'function',
|
||||||
|
name: 'legacy_lookup',
|
||||||
|
content: '{"ok":true}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: 'continue',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
|
||||||
|
expect(targetUrl).toContain('/v1/responses');
|
||||||
|
const forwardedBody = JSON.parse(options.body);
|
||||||
|
expect(forwardedBody.tools).toEqual([
|
||||||
|
{ type: 'function', name: 'legacy_lookup', parameters: { type: 'object' } },
|
||||||
|
]);
|
||||||
|
expect(forwardedBody.tool_choice).toEqual({ type: 'function', name: 'legacy_lookup' });
|
||||||
|
expect(forwardedBody.input).toContainEqual({
|
||||||
|
type: 'function_call_output',
|
||||||
|
call_id: 'legacy_lookup',
|
||||||
|
output: '{"ok":true}',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns synthetic Anthropic web_search server tool results without adding a new search dependency', async () => {
|
||||||
|
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
object: 'search.result',
|
||||||
|
data: [{ title: 'Metapi', url: 'https://example.com/metapi' }],
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/messages',
|
||||||
|
payload: {
|
||||||
|
model: 'claude-opus-4-6',
|
||||||
|
max_tokens: 256,
|
||||||
|
stream: false,
|
||||||
|
tools: [{ type: 'web_search_20250305', max_uses: 2 }],
|
||||||
|
messages: [{ role: 'user', content: 'metapi protocol compatibility' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode, response.body).toBe(200);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(selectChannelMock).toHaveBeenCalledWith('__search', expect.anything());
|
||||||
|
const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
|
||||||
|
expect(targetUrl).toBe('https://upstream.example.com/v1/search');
|
||||||
|
expect(JSON.parse(options.body)).toMatchObject({
|
||||||
|
query: 'metapi protocol compatibility',
|
||||||
|
max_results: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = response.json();
|
||||||
|
expect(body.content?.[0]).toMatchObject({
|
||||||
|
type: 'server_tool_use',
|
||||||
|
name: 'web_search',
|
||||||
|
});
|
||||||
|
expect(body.content?.[1]).toMatchObject({
|
||||||
|
type: 'web_search_tool_result',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('streams synthetic Anthropic web_search server tool results over SSE', async () => {
|
||||||
|
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
object: 'search.result',
|
||||||
|
data: [{ title: 'Metapi SSE' }],
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/messages',
|
||||||
|
payload: {
|
||||||
|
model: 'claude-opus-4-6',
|
||||||
|
max_tokens: 256,
|
||||||
|
stream: true,
|
||||||
|
tools: [{ type: 'web_search_20250305' }],
|
||||||
|
messages: [{ role: 'user', content: 'metapi sse search' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode, response.body).toBe(200);
|
||||||
|
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||||
|
expect(response.body).toContain('"type":"server_tool_use"');
|
||||||
|
expect(response.body).toContain('"type":"web_search_tool_result"');
|
||||||
|
expect(response.body).toContain('message_stop');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns synthetic Responses web_search results without touching completions upstreams', async () => {
|
||||||
|
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
object: 'search.result',
|
||||||
|
data: [{ title: 'Metapi Responses', url: 'https://example.com/responses' }],
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/responses',
|
||||||
|
payload: {
|
||||||
|
model: 'gpt-4.1',
|
||||||
|
stream: false,
|
||||||
|
tools: [{ type: 'web_search', name: 'web_search', max_results: 3 }],
|
||||||
|
input: 'metapi responses web search',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode, response.body).toBe(200);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(selectChannelMock).toHaveBeenCalledWith('__search', expect.anything());
|
||||||
|
const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
|
||||||
|
expect(targetUrl).toBe('https://upstream.example.com/v1/search');
|
||||||
|
expect(JSON.parse(options.body)).toMatchObject({
|
||||||
|
query: 'metapi responses web search',
|
||||||
|
max_results: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = response.json();
|
||||||
|
expect(body.object).toBe('response');
|
||||||
|
expect(body.output_text).toContain('Metapi Responses');
|
||||||
|
expect(body.output?.[0]).toMatchObject({
|
||||||
|
type: 'web_search_call',
|
||||||
|
status: 'completed',
|
||||||
|
action: {
|
||||||
|
type: 'search',
|
||||||
|
query: 'metapi responses web search',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(body.output?.[1]?.content?.[0]?.text).toContain('Metapi Responses');
|
||||||
|
});
|
||||||
|
|
||||||
it('routes gemini platform to OpenAI-compatible upstream endpoint path', async () => {
|
it('routes gemini platform to OpenAI-compatible upstream endpoint path', async () => {
|
||||||
selectChannelMock.mockReturnValue({
|
selectChannelMock.mockReturnValue({
|
||||||
channel: { id: 11, routeId: 22 },
|
channel: { id: 11, routeId: 22 },
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ describe('responses proxy codex oauth refresh', () => {
|
|||||||
config.proxyStickySessionEnabled = originalProxyStickySessionEnabled;
|
config.proxyStickySessionEnabled = originalProxyStickySessionEnabled;
|
||||||
config.proxySessionChannelConcurrencyLimit = originalProxySessionChannelConcurrencyLimit;
|
config.proxySessionChannelConcurrencyLimit = originalProxySessionChannelConcurrencyLimit;
|
||||||
config.proxySessionChannelQueueWaitMs = originalProxySessionChannelQueueWaitMs;
|
config.proxySessionChannelQueueWaitMs = originalProxySessionChannelQueueWaitMs;
|
||||||
|
(config as any).openAiServiceTierRules = undefined;
|
||||||
fetchMock.mockReset();
|
fetchMock.mockReset();
|
||||||
selectChannelMock.mockReset();
|
selectChannelMock.mockReset();
|
||||||
selectNextChannelMock.mockReset();
|
selectNextChannelMock.mockReset();
|
||||||
@@ -685,6 +686,137 @@ describe('responses proxy codex oauth refresh', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects external HTTP previous_response_id without touching upstream', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/responses',
|
||||||
|
payload: {
|
||||||
|
model: 'gpt-5.2-codex',
|
||||||
|
previous_response_id: 'msg_wrong_1',
|
||||||
|
input: 'hello',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json().error.message).toContain('msg_*');
|
||||||
|
expect(response.json().error.message).toContain('HTTP /v1/responses');
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects orphan external HTTP function_call_output but keeps codex session inference allowed', async () => {
|
||||||
|
const rejected = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/responses',
|
||||||
|
payload: {
|
||||||
|
model: 'gpt-5.2-codex',
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
type: 'function_call_output',
|
||||||
|
call_id: 'call_orphan',
|
||||||
|
output: '{"ok":true}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(rejected.statusCode).toBe(400);
|
||||||
|
expect(rejected.json().error.message).toContain('Responses WebSocket v2');
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
id: 'resp_codex_preflight_ok',
|
||||||
|
object: 'response',
|
||||||
|
model: 'gpt-5.2-codex',
|
||||||
|
status: 'completed',
|
||||||
|
output_text: 'accepted',
|
||||||
|
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const allowed = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/responses',
|
||||||
|
headers: {
|
||||||
|
session_id: 'session-preflight-codex',
|
||||||
|
'user-agent': 'CodexClient/1.0',
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
model: 'gpt-5.2-codex',
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
type: 'function_call_output',
|
||||||
|
call_id: 'call_codex',
|
||||||
|
output: '{"ok":true}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(allowed.statusCode).toBe(200);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters HTTP service_tier by selected account policy before forwarding upstream', async () => {
|
||||||
|
(config as any).openAiServiceTierRules = [{
|
||||||
|
action: 'filter',
|
||||||
|
tiers: ['priority'],
|
||||||
|
platforms: ['codex'],
|
||||||
|
accountTypes: ['plus'],
|
||||||
|
}];
|
||||||
|
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
id: 'resp_codex_tier_filtered',
|
||||||
|
object: 'response',
|
||||||
|
model: 'gpt-5.2-codex',
|
||||||
|
status: 'completed',
|
||||||
|
output_text: 'tier filtered',
|
||||||
|
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/responses',
|
||||||
|
payload: {
|
||||||
|
model: 'gpt-5.2-codex',
|
||||||
|
service_tier: 'fast',
|
||||||
|
input: 'hello',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [, options] = fetchMock.mock.calls[0] as [string, any];
|
||||||
|
const forwardedBody = JSON.parse(options.body);
|
||||||
|
expect(forwardedBody.service_tier).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks HTTP service_tier by selected account policy without touching upstream', async () => {
|
||||||
|
(config as any).openAiServiceTierRules = [{
|
||||||
|
action: 'block',
|
||||||
|
tiers: ['priority'],
|
||||||
|
platforms: ['codex'],
|
||||||
|
accountTypes: ['plus'],
|
||||||
|
}];
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/responses',
|
||||||
|
payload: {
|
||||||
|
model: 'gpt-5.2-codex',
|
||||||
|
service_tier: 'fast',
|
||||||
|
input: 'hello',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json().error.message).toContain('service_tier');
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('infers previous_response_id for codex tool-output follow-up turns when the client only sends conversation_id', async () => {
|
it('infers previous_response_id for codex tool-output follow-up turns when the client only sends conversation_id', async () => {
|
||||||
fetchMock
|
fetchMock
|
||||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
@@ -927,17 +1059,9 @@ describe('responses proxy codex oauth refresh', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(400);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
expect(response.json().error.message).toContain('HTTP /v1/responses');
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
const [, firstOptions] = fetchMock.mock.calls[0] as [string, any];
|
|
||||||
const [, secondOptions] = fetchMock.mock.calls[1] as [string, any];
|
|
||||||
const firstBody = JSON.parse(firstOptions.body);
|
|
||||||
const secondBody = JSON.parse(secondOptions.body);
|
|
||||||
|
|
||||||
expect(firstBody.previous_response_id).toBe('resp_stale');
|
|
||||||
expect(secondBody.previous_response_id).toBeUndefined();
|
|
||||||
expect(secondBody.input).toEqual(firstBody.input);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('strips generic downstream headers before forwarding codex responses upstream', async () => {
|
it('strips generic downstream headers before forwarding codex responses upstream', async () => {
|
||||||
|
|||||||
@@ -395,6 +395,7 @@ describe('responses websocket transport', () => {
|
|||||||
upstreamRequests = [];
|
upstreamRequests = [];
|
||||||
(config as any).codexResponsesWebsocketBeta = originalCodexResponsesWebsocketBeta;
|
(config as any).codexResponsesWebsocketBeta = originalCodexResponsesWebsocketBeta;
|
||||||
(config as any).codexUpstreamWebsocketEnabled = true;
|
(config as any).codexUpstreamWebsocketEnabled = true;
|
||||||
|
(config as any).openAiServiceTierRules = undefined;
|
||||||
rejectedUpgradeStatus = 426;
|
rejectedUpgradeStatus = 426;
|
||||||
rejectedUpgradeStatusText = 'Upgrade Required';
|
rejectedUpgradeStatusText = 'Upgrade Required';
|
||||||
rejectedUpgradeBody = 'Upgrade Required';
|
rejectedUpgradeBody = 'Upgrade Required';
|
||||||
@@ -1556,6 +1557,79 @@ describe('responses websocket transport', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('applies service_tier policy to websocket frames before upstream dispatch', async () => {
|
||||||
|
(config as any).openAiServiceTierRules = [{
|
||||||
|
action: 'filter',
|
||||||
|
tiers: ['priority'],
|
||||||
|
platforms: ['openai'],
|
||||||
|
}];
|
||||||
|
const selectedChannel = createSelectedChannel({
|
||||||
|
sitePlatform: 'openai',
|
||||||
|
actualModel: 'gpt-4.1',
|
||||||
|
});
|
||||||
|
selectChannelMock.mockReturnValue(selectedChannel);
|
||||||
|
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
|
||||||
|
fetchMock.mockResolvedValueOnce(createSseResponse([
|
||||||
|
'event: response.completed\n',
|
||||||
|
'data: {"type":"response.completed","response":{"id":"resp_ws_tier","model":"gpt-4.1","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}\n\n',
|
||||||
|
'data: [DONE]\n\n',
|
||||||
|
]));
|
||||||
|
|
||||||
|
const socket = createClientSocket(baseUrl);
|
||||||
|
await waitForSocketOpen(socket);
|
||||||
|
const responsePromise = waitForSocketMessageMatching(
|
||||||
|
socket,
|
||||||
|
(message) => message?.type === 'response.completed',
|
||||||
|
);
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'response.create',
|
||||||
|
model: 'gpt-4.1',
|
||||||
|
service_tier: 'fast',
|
||||||
|
input: [],
|
||||||
|
}));
|
||||||
|
await responsePromise;
|
||||||
|
socket.close();
|
||||||
|
|
||||||
|
const [, options] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
|
const forwardedBody = JSON.parse(String(options.body));
|
||||||
|
expect(forwardedBody.service_tier).toBeUndefined();
|
||||||
|
(config as any).openAiServiceTierRules = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks websocket service_tier before upstream dispatch', async () => {
|
||||||
|
(config as any).openAiServiceTierRules = [{
|
||||||
|
action: 'block',
|
||||||
|
tiers: ['priority'],
|
||||||
|
platforms: ['openai'],
|
||||||
|
}];
|
||||||
|
const selectedChannel = createSelectedChannel({
|
||||||
|
sitePlatform: 'openai',
|
||||||
|
actualModel: 'gpt-4.1',
|
||||||
|
});
|
||||||
|
selectChannelMock.mockReturnValue(selectedChannel);
|
||||||
|
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
|
||||||
|
|
||||||
|
const socket = createClientSocket(baseUrl);
|
||||||
|
await waitForSocketOpen(socket);
|
||||||
|
const errorPromise = waitForSocketMessageMatching(
|
||||||
|
socket,
|
||||||
|
(message) => message?.type === 'error',
|
||||||
|
);
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'response.create',
|
||||||
|
model: 'gpt-4.1',
|
||||||
|
service_tier: 'fast',
|
||||||
|
input: [],
|
||||||
|
}));
|
||||||
|
const message = await errorPromise;
|
||||||
|
socket.close();
|
||||||
|
|
||||||
|
expect(message.status).toBe(400);
|
||||||
|
expect(message.error.message).toContain('service_tier');
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
(config as any).openAiServiceTierRules = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps streamed output items for follow-up turns when the terminal HTTP fallback payload has an empty output array', async () => {
|
it('keeps streamed output items for follow-up turns when the terminal HTTP fallback payload has an empty output array', async () => {
|
||||||
const selectedChannel = createSelectedChannel({
|
const selectedChannel = createSelectedChannel({
|
||||||
sitePlatform: 'openai',
|
sitePlatform: 'openai',
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ import {
|
|||||||
import { runWithSiteApiEndpointPool, SiteApiEndpointRequestError } from '../../services/siteApiEndpointService.js';
|
import { runWithSiteApiEndpointPool, SiteApiEndpointRequestError } from '../../services/siteApiEndpointService.js';
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { buildOauthProviderHeaders } from '../../services/oauth/service.js';
|
import { buildOauthProviderHeaders } from '../../services/oauth/service.js';
|
||||||
|
import { getOauthInfoFromAccount } from '../../services/oauth/oauthAccount.js';
|
||||||
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
||||||
import { buildUpstreamEndpointRequest } from './upstreamEndpoint.js';
|
import { buildUpstreamEndpointRequest } from './upstreamEndpoint.js';
|
||||||
import { config } from '../../config.js';
|
import { config } from '../../config.js';
|
||||||
|
import { applyOpenAiServiceTierPolicy } from '../../proxy-core/serviceTierPolicy.js';
|
||||||
|
|
||||||
const installedApps = new WeakSet<FastifyInstance>();
|
const installedApps = new WeakSet<FastifyInstance>();
|
||||||
const WS_TURN_STATE_HEADER = 'x-codex-turn-state';
|
const WS_TURN_STATE_HEADER = 'x-codex-turn-state';
|
||||||
@@ -43,6 +45,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getServiceTierPolicyRules(): unknown {
|
||||||
|
return (config as typeof config & { openAiServiceTierRules?: unknown }).openAiServiceTierRules;
|
||||||
|
}
|
||||||
|
|
||||||
function asTrimmedString(value: unknown): string {
|
function asTrimmedString(value: unknown): string {
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
}
|
}
|
||||||
@@ -592,6 +598,24 @@ async function handleResponsesWebsocketConnection(
|
|||||||
writeResponsesWebsocketError(socket, 403, 'model is not allowed for this downstream key');
|
writeResponsesWebsocketError(socket, 403, 'model is not allowed for this downstream key');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const serviceTierPolicy = applyOpenAiServiceTierPolicy({
|
||||||
|
body: parsed,
|
||||||
|
context: {
|
||||||
|
requestedModel: requestModel,
|
||||||
|
},
|
||||||
|
rules: getServiceTierPolicyRules(),
|
||||||
|
});
|
||||||
|
if (!serviceTierPolicy.ok) {
|
||||||
|
writeResponsesWebsocketError(
|
||||||
|
socket,
|
||||||
|
serviceTierPolicy.statusCode,
|
||||||
|
serviceTierPolicy.payload.error.message,
|
||||||
|
serviceTierPolicy.payload,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
parsed.service_tier = serviceTierPolicy.body.service_tier;
|
||||||
|
if (serviceTierPolicy.body.service_tier === undefined) delete parsed.service_tier;
|
||||||
const supportsIncrementalInput = selectedChannelSupportsIncrementalInput(selectedChannel, requestModel)
|
const supportsIncrementalInput = selectedChannelSupportsIncrementalInput(selectedChannel, requestModel)
|
||||||
|| await supportsResponsesWebsocketIncrementalInput(parsed, lastRequest, authContext);
|
|| await supportsResponsesWebsocketIncrementalInput(parsed, lastRequest, authContext);
|
||||||
const shouldHandleLocalPrewarm = shouldHandleResponsesWebsocketPrewarmLocally(
|
const shouldHandleLocalPrewarm = shouldHandleResponsesWebsocketPrewarmLocally(
|
||||||
@@ -614,8 +638,8 @@ async function handleResponsesWebsocketConnection(
|
|||||||
await consumeManagedKeyRequest(authContext.key.id);
|
await consumeManagedKeyRequest(authContext.key.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
lastRequest = normalized.nextRequestSnapshot;
|
|
||||||
if (shouldHandleLocalPrewarm) {
|
if (shouldHandleLocalPrewarm) {
|
||||||
|
lastRequest = normalized.nextRequestSnapshot;
|
||||||
lastResponseOutput = [];
|
lastResponseOutput = [];
|
||||||
for (const payload of synthesizePrewarmResponsePayloads(normalized.request)) {
|
for (const payload of synthesizePrewarmResponsePayloads(normalized.request)) {
|
||||||
socket.send(JSON.stringify(payload));
|
socket.send(JSON.stringify(payload));
|
||||||
@@ -629,6 +653,35 @@ async function handleResponsesWebsocketConnection(
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedServiceTierPolicy = applyOpenAiServiceTierPolicy({
|
||||||
|
body: normalized.request,
|
||||||
|
context: {
|
||||||
|
requestedModel: requestModel,
|
||||||
|
actualModel: asTrimmedString(selectedChannel?.actualModel),
|
||||||
|
sitePlatform: asTrimmedString(selectedChannel?.site?.platform),
|
||||||
|
accountType: getOauthInfoFromAccount(selectedChannel?.account)?.planType,
|
||||||
|
},
|
||||||
|
rules: getServiceTierPolicyRules(),
|
||||||
|
});
|
||||||
|
if (!selectedServiceTierPolicy.ok) {
|
||||||
|
writeResponsesWebsocketError(
|
||||||
|
socket,
|
||||||
|
selectedServiceTierPolicy.statusCode,
|
||||||
|
selectedServiceTierPolicy.payload.error.message,
|
||||||
|
selectedServiceTierPolicy.payload,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
normalized.request = selectedServiceTierPolicy.body;
|
||||||
|
normalized.nextRequestSnapshot = {
|
||||||
|
...normalized.nextRequestSnapshot,
|
||||||
|
service_tier: selectedServiceTierPolicy.body.service_tier,
|
||||||
|
};
|
||||||
|
if (selectedServiceTierPolicy.body.service_tier === undefined) {
|
||||||
|
delete normalized.nextRequestSnapshot.service_tier;
|
||||||
|
}
|
||||||
|
lastRequest = normalized.nextRequestSnapshot;
|
||||||
|
|
||||||
const codexWebsocketChannel = selectedChannelSupportsCodexWebsocketTransport(selectedChannel, requestModel)
|
const codexWebsocketChannel = selectedChannelSupportsCodexWebsocketTransport(selectedChannel, requestModel)
|
||||||
? selectedChannel
|
? selectedChannel
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -44,4 +44,14 @@ describe('applyRuntimeSettings', () => {
|
|||||||
|
|
||||||
expect(config.smtpPort).toBe(587);
|
expect(config.smtpPort).toBe(587);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hydrates legacy double-encoded global model allowlist values', () => {
|
||||||
|
config.globalAllowedModels = [];
|
||||||
|
|
||||||
|
applyRuntimeSettings(new Map([
|
||||||
|
['global_allowed_models', JSON.stringify(JSON.stringify(['model-alpha', ' model-beta ', 'model-gamma']))],
|
||||||
|
]));
|
||||||
|
|
||||||
|
expect(config.globalAllowedModels).toEqual(['model-alpha', 'model-beta', 'model-gamma']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ function toStringList(value: unknown): string[] {
|
|||||||
.filter((item) => item.length > 0);
|
.filter((item) => item.length > 0);
|
||||||
}
|
}
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return toStringList(parsed);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to comma splitting for legacy plain-string lists.
|
||||||
|
}
|
||||||
return value
|
return value
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((item) => item.trim())
|
.map((item) => item.trim())
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
fetchDockerHubTagCandidates,
|
fetchDockerHubTagCandidates,
|
||||||
fetchLatestStableGitHubRelease,
|
fetchLatestStableGitHubRelease,
|
||||||
getCurrentRuntimeVersion,
|
getCurrentRuntimeVersion,
|
||||||
type DockerHubTagCandidates,
|
|
||||||
type UpdateCenterVersionCandidate,
|
type UpdateCenterVersionCandidate,
|
||||||
} from './updateCenterVersionService.js';
|
} from './updateCenterVersionService.js';
|
||||||
import {
|
import {
|
||||||
@@ -164,8 +163,8 @@ export async function buildUpdateCenterStatus(): Promise<UpdateCenterStatusResul
|
|||||||
loadUpdateCenterRuntimeState(),
|
loadUpdateCenterRuntimeState(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const githubRelease = githubLookup.value as UpdateCenterVersionCandidate | null;
|
const githubRelease = githubLookup.value;
|
||||||
const dockerHubCandidates = dockerLookup.value as DockerHubTagCandidates | null;
|
const dockerHubCandidates = dockerLookup.value;
|
||||||
const dockerHubTag = dockerHubCandidates?.primary || null;
|
const dockerHubTag = dockerHubCandidates?.primary || null;
|
||||||
const dockerHubRecentTags = dockerHubCandidates?.recentNonStable || [];
|
const dockerHubRecentTags = dockerHubCandidates?.recentNonStable || [];
|
||||||
const helper = (helperLookup.value as UpdateCenterHelperStatus | null) || buildUnavailableHelperStatus(helperLookup.error);
|
const helper = (helperLookup.value as UpdateCenterHelperStatus | null) || buildUnavailableHelperStatus(helperLookup.error);
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { convertOpenAiBodyToResponsesBody } from '../../transformers/openai/responses/conversion.js';
|
||||||
|
import {
|
||||||
|
convertAnthropicToolsToOpenAi,
|
||||||
|
convertOpenAiBodyToAnthropicMessagesBody,
|
||||||
|
convertOpenAiToolsToAnthropic,
|
||||||
|
} from '../../transformers/anthropic/messages/conversion.js';
|
||||||
|
import { convertClaudeRequestToOpenAiBody } from '../../transformers/shared/chatFormatsCore.js';
|
||||||
|
import { validateExternalResponsesHttpRequest } from '../../proxy-core/responsesPreflight.js';
|
||||||
|
import { applyOpenAiServiceTierPolicy } from '../../proxy-core/serviceTierPolicy.js';
|
||||||
|
|
||||||
|
describe('non-cache cross-protocol field matrix', () => {
|
||||||
|
it('audits web_search, legacy functions/function_call, continuation diagnostics, function outputs and service_tier', () => {
|
||||||
|
const legacyChatToResponses = convertOpenAiBodyToResponsesBody(
|
||||||
|
{
|
||||||
|
model: 'gpt-5',
|
||||||
|
messages: [
|
||||||
|
{ role: 'user', content: 'hello' },
|
||||||
|
{ role: 'function', name: 'legacy_tool', content: 'done' },
|
||||||
|
],
|
||||||
|
functions: [{ name: 'legacy_tool', parameters: { type: 'object' } }],
|
||||||
|
function_call: { name: 'legacy_tool' },
|
||||||
|
},
|
||||||
|
'gpt-5',
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(legacyChatToResponses).toMatchObject({
|
||||||
|
tools: [{ type: 'function', name: 'legacy_tool' }],
|
||||||
|
tool_choice: { type: 'function', name: 'legacy_tool' },
|
||||||
|
});
|
||||||
|
expect((legacyChatToResponses.input as any[]).some((item) => item.type === 'function_call_output')).toBe(true);
|
||||||
|
|
||||||
|
expect(convertOpenAiToolsToAnthropic([
|
||||||
|
{ type: 'web_search' },
|
||||||
|
{ type: 'google_search' },
|
||||||
|
])).toEqual([
|
||||||
|
{ type: 'web_search_20250305', name: 'web_search' },
|
||||||
|
{ type: 'web_search_20250305', name: 'web_search' },
|
||||||
|
]);
|
||||||
|
expect(convertAnthropicToolsToOpenAi([
|
||||||
|
{ type: 'web_search_20250305' },
|
||||||
|
])).toEqual([
|
||||||
|
{ type: 'web_search', name: 'web_search' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const claudeToChat = convertClaudeRequestToOpenAiBody({
|
||||||
|
model: 'claude-opus',
|
||||||
|
max_tokens: 256,
|
||||||
|
tools: [{ type: 'web_search_20250305' }],
|
||||||
|
messages: [{ role: 'user', content: 'search' }],
|
||||||
|
});
|
||||||
|
expect(claudeToChat.payload.tools).toEqual([{ type: 'web_search', name: 'web_search' }]);
|
||||||
|
|
||||||
|
const openAiToClaude = convertOpenAiBodyToAnthropicMessagesBody(
|
||||||
|
{
|
||||||
|
model: 'gpt-5',
|
||||||
|
messages: [{ role: 'user', content: 'search' }],
|
||||||
|
tools: [{ type: 'web_search' }],
|
||||||
|
},
|
||||||
|
'claude-opus',
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(openAiToClaude.tools).toEqual([{ type: 'web_search_20250305', name: 'web_search' }]);
|
||||||
|
|
||||||
|
const previousResponseDiagnostic = validateExternalResponsesHttpRequest({
|
||||||
|
model: 'gpt-5',
|
||||||
|
previous_response_id: 'msg_wrong',
|
||||||
|
input: 'hello',
|
||||||
|
});
|
||||||
|
expect(previousResponseDiagnostic.ok).toBe(false);
|
||||||
|
if (!previousResponseDiagnostic.ok) {
|
||||||
|
expect(previousResponseDiagnostic.payload.error.message).toContain('msg_*');
|
||||||
|
}
|
||||||
|
|
||||||
|
const functionOutputDiagnostic = validateExternalResponsesHttpRequest({
|
||||||
|
model: 'gpt-5',
|
||||||
|
input: [{ type: 'function_call_output', call_id: 'call_missing', output: 'done' }],
|
||||||
|
});
|
||||||
|
expect(functionOutputDiagnostic.ok).toBe(false);
|
||||||
|
if (!functionOutputDiagnostic.ok) {
|
||||||
|
expect(functionOutputDiagnostic.payload.error.message).toContain('Responses WebSocket v2');
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(applyOpenAiServiceTierPolicy({
|
||||||
|
body: { model: 'gpt-5', service_tier: 'fast' },
|
||||||
|
})).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
body: { model: 'gpt-5', service_tier: 'priority' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
convertAnthropicToolsToOpenAi,
|
||||||
convertOpenAiBodyToAnthropicMessagesBody,
|
convertOpenAiBodyToAnthropicMessagesBody,
|
||||||
convertOpenAiToolChoiceToAnthropic,
|
convertOpenAiToolChoiceToAnthropic,
|
||||||
|
convertOpenAiToolsToAnthropic,
|
||||||
sanitizeAnthropicMessagesBody,
|
sanitizeAnthropicMessagesBody,
|
||||||
} from './conversion.js';
|
} from './conversion.js';
|
||||||
import { anthropicMessagesInbound } from './inbound.js';
|
import { anthropicMessagesInbound } from './inbound.js';
|
||||||
@@ -29,6 +31,24 @@ describe('sanitizeAnthropicMessagesBody', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('maps web_search server tools between OpenAI-compatible and Anthropic Messages requests', () => {
|
||||||
|
expect(convertOpenAiToolsToAnthropic([
|
||||||
|
{ type: 'web_search', max_results: 3 },
|
||||||
|
{ type: 'google_search' },
|
||||||
|
])).toEqual([
|
||||||
|
{ type: 'web_search_20250305', max_results: 3, name: 'web_search' },
|
||||||
|
{ type: 'web_search_20250305', name: 'web_search' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(convertAnthropicToolsToOpenAi([
|
||||||
|
{ type: 'web_search_20250305', max_uses: 2 },
|
||||||
|
{ name: 'lookup_weather', input_schema: { type: 'object' } },
|
||||||
|
])).toEqual([
|
||||||
|
{ type: 'web_search', max_uses: 2, name: 'web_search' },
|
||||||
|
{ name: 'lookup_weather', input_schema: { type: 'object' } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('normalizes string system and message content before rebuilding cache anchors', () => {
|
it('normalizes string system and message content before rebuilding cache anchors', () => {
|
||||||
const result = sanitizeAnthropicMessagesBody({
|
const result = sanitizeAnthropicMessagesBody({
|
||||||
model: 'claude-opus-4-6',
|
model: 'claude-opus-4-6',
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ const VALID_ANTHROPIC_THINKING_TYPES = new Set(['enabled', 'disabled', 'adaptive
|
|||||||
const VALID_ANTHROPIC_EFFORTS = new Set(['low', 'medium', 'high', 'max']);
|
const VALID_ANTHROPIC_EFFORTS = new Set(['low', 'medium', 'high', 'max']);
|
||||||
const MAX_ANTHROPIC_CACHE_CONTROL_BREAKPOINTS = 4;
|
const MAX_ANTHROPIC_CACHE_CONTROL_BREAKPOINTS = 4;
|
||||||
const ADAPTIVE_ANTHROPIC_CACHE_CONTROL_BLOCK_WINDOW = 20;
|
const ADAPTIVE_ANTHROPIC_CACHE_CONTROL_BLOCK_WINDOW = 20;
|
||||||
|
const ANTHROPIC_WEB_SEARCH_TOOL_TYPES = new Set([
|
||||||
|
'web_search',
|
||||||
|
'web_search_20250305',
|
||||||
|
'google_search',
|
||||||
|
]);
|
||||||
|
|
||||||
function asTrimmedString(value: unknown): string {
|
function asTrimmedString(value: unknown): string {
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
@@ -181,6 +186,20 @@ function normalizeOpenAiToolArguments(raw: unknown): string {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAnthropicWebSearchTool(value: Record<string, unknown>): boolean {
|
||||||
|
const type = asTrimmedString(value.type).toLowerCase();
|
||||||
|
const name = asTrimmedString(value.name).toLowerCase();
|
||||||
|
return ANTHROPIC_WEB_SEARCH_TOOL_TYPES.has(type) || name === 'web_search' || name === 'google_search';
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertAnthropicWebSearchToolToOpenAi(value: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const next: Record<string, unknown> = { ...value };
|
||||||
|
const type = asTrimmedString(value.type).toLowerCase();
|
||||||
|
next.type = type === 'google_search' ? 'web_search' : 'web_search';
|
||||||
|
next.name = 'web_search';
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeAnthropicToolInput(raw: unknown): unknown {
|
export function normalizeAnthropicToolInput(raw: unknown): unknown {
|
||||||
if (raw === undefined || raw === null) return {};
|
if (raw === undefined || raw === null) return {};
|
||||||
if (isRecord(raw) || Array.isArray(raw)) return raw;
|
if (isRecord(raw) || Array.isArray(raw)) return raw;
|
||||||
@@ -802,6 +821,14 @@ export function convertOpenAiToolsToAnthropic(rawTools: unknown): unknown {
|
|||||||
if (!isRecord(item)) return null;
|
if (!isRecord(item)) return null;
|
||||||
|
|
||||||
const type = asTrimmedString(item.type).toLowerCase();
|
const type = asTrimmedString(item.type).toLowerCase();
|
||||||
|
if (isAnthropicWebSearchTool(item)) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
type: 'web_search_20250305',
|
||||||
|
name: 'web_search',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (type === 'function' && isRecord(item.function)) {
|
if (type === 'function' && isRecord(item.function)) {
|
||||||
const fn = item.function;
|
const fn = item.function;
|
||||||
const name = asTrimmedString(fn.name);
|
const name = asTrimmedString(fn.name);
|
||||||
@@ -826,6 +853,16 @@ export function convertOpenAiToolsToAnthropic(rawTools: unknown): unknown {
|
|||||||
return converted.length > 0 ? converted : rawTools;
|
return converted.length > 0 ? converted : rawTools;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function convertAnthropicToolsToOpenAi(rawTools: unknown): unknown {
|
||||||
|
if (!Array.isArray(rawTools)) return rawTools;
|
||||||
|
|
||||||
|
return rawTools.map((item) => {
|
||||||
|
if (!isRecord(item)) return item;
|
||||||
|
if (isAnthropicWebSearchTool(item)) return convertAnthropicWebSearchToolToOpenAi(item);
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function convertOpenAiToolChoiceToAnthropic(rawToolChoice: unknown): unknown {
|
export function convertOpenAiToolChoiceToAnthropic(rawToolChoice: unknown): unknown {
|
||||||
if (rawToolChoice === undefined) return undefined;
|
if (rawToolChoice === undefined) return undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -1059,6 +1059,79 @@ describe('convertOpenAiBodyToResponsesBody', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('maps legacy chat functions, function_call and role:function messages into Responses request fields', () => {
|
||||||
|
const result = convertOpenAiBodyToResponsesBody(
|
||||||
|
{
|
||||||
|
model: 'gpt-5',
|
||||||
|
messages: [
|
||||||
|
{ role: 'user', content: 'lookup weather' },
|
||||||
|
{ role: 'function', name: 'legacy_weather', content: '{"temp":21}' },
|
||||||
|
],
|
||||||
|
functions: [
|
||||||
|
{
|
||||||
|
name: 'legacy_weather',
|
||||||
|
description: 'Lookup weather',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { city: { type: 'string' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
function_call: { name: 'legacy_weather' },
|
||||||
|
},
|
||||||
|
'gpt-5',
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.tools).toEqual([
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
name: 'legacy_weather',
|
||||||
|
description: 'Lookup weather',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { city: { type: 'string' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(result.tool_choice).toEqual({
|
||||||
|
type: 'function',
|
||||||
|
name: 'legacy_weather',
|
||||||
|
});
|
||||||
|
expect(result.input).toEqual([
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'input_text', text: 'lookup weather' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function_call_output',
|
||||||
|
call_id: 'legacy_weather',
|
||||||
|
output: '{"temp":21}',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves OpenAI web_search and google_search server tools in Responses request bodies', () => {
|
||||||
|
const result = convertOpenAiBodyToResponsesBody(
|
||||||
|
{
|
||||||
|
model: 'gpt-5',
|
||||||
|
messages: [{ role: 'user', content: 'search docs' }],
|
||||||
|
tools: [
|
||||||
|
{ type: 'web_search_preview', search_context_size: 'low' },
|
||||||
|
{ type: 'google_search', max_results: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'gpt-5',
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.tools).toEqual([
|
||||||
|
{ type: 'web_search_preview', search_context_size: 'low', name: 'web_search' },
|
||||||
|
{ type: 'web_search', max_results: 3, name: 'web_search' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('drops function tools with blank names when converting OpenAI-compatible input into Responses bodies', () => {
|
it('drops function tools with blank names when converting OpenAI-compatible input into Responses bodies', () => {
|
||||||
const result = convertOpenAiBodyToResponsesBody(
|
const result = convertOpenAiBodyToResponsesBody(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -71,6 +71,19 @@ function cloneJsonValue<T>(value: T): T {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const OPENAI_WEB_SEARCH_TOOL_TYPES = new Set([
|
||||||
|
'web_search',
|
||||||
|
'web_search_preview',
|
||||||
|
'web_search_preview_2025_03_11',
|
||||||
|
'google_search',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isOpenAiWebSearchToolRecord(item: Record<string, unknown>): boolean {
|
||||||
|
const type = asTrimmedString(item.type).toLowerCase();
|
||||||
|
const name = asTrimmedString(item.name).toLowerCase();
|
||||||
|
return OPENAI_WEB_SEARCH_TOOL_TYPES.has(type) || name === 'web_search' || name === 'google_search';
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeOptionalTrimmedString(value: unknown): string | undefined {
|
function normalizeOptionalTrimmedString(value: unknown): string | undefined {
|
||||||
const trimmed = asTrimmedString(value);
|
const trimmed = asTrimmedString(value);
|
||||||
return trimmed || undefined;
|
return trimmed || undefined;
|
||||||
@@ -287,11 +300,22 @@ function collectOpenAiToolNames(body: Record<string, unknown>): string[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rawFunctions = Array.isArray(body.functions) ? body.functions : [];
|
||||||
|
for (const item of rawFunctions) {
|
||||||
|
if (!isRecord(item)) continue;
|
||||||
|
pushName(item.name);
|
||||||
|
}
|
||||||
|
|
||||||
const toolChoice = isRecord(body.tool_choice) ? body.tool_choice : null;
|
const toolChoice = isRecord(body.tool_choice) ? body.tool_choice : null;
|
||||||
if (toolChoice && asTrimmedString(toolChoice.type).toLowerCase() === 'function') {
|
if (toolChoice && asTrimmedString(toolChoice.type).toLowerCase() === 'function') {
|
||||||
pushName(isRecord(toolChoice.function) ? toolChoice.function.name : toolChoice.name);
|
pushName(isRecord(toolChoice.function) ? toolChoice.function.name : toolChoice.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const legacyFunctionCall = isRecord(body.function_call) ? body.function_call : null;
|
||||||
|
if (legacyFunctionCall) {
|
||||||
|
pushName(legacyFunctionCall.name);
|
||||||
|
}
|
||||||
|
|
||||||
const rawMessages = Array.isArray(body.messages) ? body.messages : [];
|
const rawMessages = Array.isArray(body.messages) ? body.messages : [];
|
||||||
for (const message of rawMessages) {
|
for (const message of rawMessages) {
|
||||||
if (!isRecord(message) || asTrimmedString(message.role).toLowerCase() !== 'assistant') continue;
|
if (!isRecord(message) || asTrimmedString(message.role).toLowerCase() !== 'assistant') continue;
|
||||||
@@ -344,6 +368,14 @@ function convertOpenAiToolsToResponses(
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isOpenAiWebSearchToolRecord(item)) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
type: type === 'google_search' ? 'web_search' : (type || 'web_search'),
|
||||||
|
name: asTrimmedString(item.name) || 'web_search',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (type === 'custom' && asTrimmedString(item.name)) {
|
if (type === 'custom' && asTrimmedString(item.name)) {
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
@@ -355,6 +387,43 @@ function convertOpenAiToolsToResponses(
|
|||||||
return converted;
|
return converted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function convertOpenAiFunctionsToResponses(
|
||||||
|
rawFunctions: unknown,
|
||||||
|
toolNameMap: Record<string, string>,
|
||||||
|
): Array<Record<string, unknown>> {
|
||||||
|
if (!Array.isArray(rawFunctions)) return [];
|
||||||
|
|
||||||
|
return rawFunctions
|
||||||
|
.map((item) => {
|
||||||
|
if (!isRecord(item)) return null;
|
||||||
|
const name = asTrimmedString(item.name);
|
||||||
|
if (!name) return null;
|
||||||
|
|
||||||
|
const mapped: Record<string, unknown> = {
|
||||||
|
type: 'function',
|
||||||
|
name: getShortToolName(name, toolNameMap),
|
||||||
|
};
|
||||||
|
const description = asTrimmedString(item.description);
|
||||||
|
if (description) mapped.description = description;
|
||||||
|
if (item.parameters !== undefined) mapped.parameters = item.parameters;
|
||||||
|
if (item.strict !== undefined) mapped.strict = item.strict;
|
||||||
|
return mapped;
|
||||||
|
})
|
||||||
|
.filter((item): item is Record<string, unknown> => !!item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeOpenAiToolsAndFunctionsToResponses(
|
||||||
|
rawTools: unknown,
|
||||||
|
rawFunctions: unknown,
|
||||||
|
toolNameMap: Record<string, string>,
|
||||||
|
): unknown {
|
||||||
|
const convertedTools = convertOpenAiToolsToResponses(rawTools, toolNameMap);
|
||||||
|
const toolList = Array.isArray(convertedTools) ? convertedTools : [];
|
||||||
|
const functionList = convertOpenAiFunctionsToResponses(rawFunctions, toolNameMap);
|
||||||
|
if (toolList.length <= 0 && functionList.length <= 0) return convertedTools;
|
||||||
|
return [...toolList, ...functionList];
|
||||||
|
}
|
||||||
|
|
||||||
function convertOpenAiToolChoiceToResponses(
|
function convertOpenAiToolChoiceToResponses(
|
||||||
rawToolChoice: unknown,
|
rawToolChoice: unknown,
|
||||||
toolNameMap: Record<string, string>,
|
toolNameMap: Record<string, string>,
|
||||||
@@ -373,6 +442,26 @@ function convertOpenAiToolChoiceToResponses(
|
|||||||
return rawToolChoice;
|
return rawToolChoice;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function convertOpenAiFunctionCallToResponsesToolChoice(
|
||||||
|
rawFunctionCall: unknown,
|
||||||
|
toolNameMap: Record<string, string>,
|
||||||
|
): unknown {
|
||||||
|
if (rawFunctionCall === undefined) return undefined;
|
||||||
|
if (typeof rawFunctionCall === 'string') {
|
||||||
|
const normalized = rawFunctionCall.trim().toLowerCase();
|
||||||
|
if (normalized === 'none' || normalized === 'auto' || normalized === 'required') return normalized;
|
||||||
|
return rawFunctionCall;
|
||||||
|
}
|
||||||
|
if (!isRecord(rawFunctionCall)) return rawFunctionCall;
|
||||||
|
|
||||||
|
const name = asTrimmedString(rawFunctionCall.name);
|
||||||
|
if (!name) return 'required';
|
||||||
|
return {
|
||||||
|
type: 'function',
|
||||||
|
name: getShortToolName(name, toolNameMap),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeResponsesBodyForCompatibility(
|
function normalizeResponsesBodyForCompatibility(
|
||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
@@ -596,6 +685,23 @@ export function convertOpenAiBodyToResponsesBody(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (role === 'function') {
|
||||||
|
const callId = asTrimmedString(item.name) || asTrimmedString(item.id);
|
||||||
|
if (!callId) continue;
|
||||||
|
const output = normalizeToolOutput(item.content);
|
||||||
|
inputItems.push({
|
||||||
|
type: 'function_call_output',
|
||||||
|
call_id: callId,
|
||||||
|
output: (
|
||||||
|
(typeof output === 'string' && output === '')
|
||||||
|
|| (Array.isArray(output) && output.length === 0)
|
||||||
|
)
|
||||||
|
? '(empty)'
|
||||||
|
: output,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedContent = normalizeResponsesMessageContent('user', item.content);
|
const normalizedContent = normalizeResponsesMessageContent('user', item.content);
|
||||||
if (normalizedContent.length <= 0) continue;
|
if (normalizedContent.length <= 0) continue;
|
||||||
inputItems.push({
|
inputItems.push({
|
||||||
@@ -639,7 +745,13 @@ export function convertOpenAiBodyToResponsesBody(
|
|||||||
if (openaiBody.reasoning_budget !== undefined) body.reasoning_budget = openaiBody.reasoning_budget;
|
if (openaiBody.reasoning_budget !== undefined) body.reasoning_budget = openaiBody.reasoning_budget;
|
||||||
if (openaiBody.reasoning_summary !== undefined) body.reasoning_summary = openaiBody.reasoning_summary;
|
if (openaiBody.reasoning_summary !== undefined) body.reasoning_summary = openaiBody.reasoning_summary;
|
||||||
if (openaiBody.parallel_tool_calls !== undefined) body.parallel_tool_calls = openaiBody.parallel_tool_calls;
|
if (openaiBody.parallel_tool_calls !== undefined) body.parallel_tool_calls = openaiBody.parallel_tool_calls;
|
||||||
if (openaiBody.tools !== undefined) body.tools = convertOpenAiToolsToResponses(openaiBody.tools, toolNameMap);
|
if (openaiBody.tools !== undefined || openaiBody.functions !== undefined) {
|
||||||
|
body.tools = mergeOpenAiToolsAndFunctionsToResponses(
|
||||||
|
openaiBody.tools,
|
||||||
|
openaiBody.functions,
|
||||||
|
toolNameMap,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (openaiBody.safety_identifier !== undefined) body.safety_identifier = openaiBody.safety_identifier;
|
if (openaiBody.safety_identifier !== undefined) body.safety_identifier = openaiBody.safety_identifier;
|
||||||
if (openaiBody.max_tool_calls !== undefined) body.max_tool_calls = openaiBody.max_tool_calls;
|
if (openaiBody.max_tool_calls !== undefined) body.max_tool_calls = openaiBody.max_tool_calls;
|
||||||
if (openaiBody.prompt_cache_key !== undefined) body.prompt_cache_key = openaiBody.prompt_cache_key;
|
if (openaiBody.prompt_cache_key !== undefined) body.prompt_cache_key = openaiBody.prompt_cache_key;
|
||||||
@@ -665,7 +777,9 @@ export function convertOpenAiBodyToResponsesBody(
|
|||||||
body.text = textConfig;
|
body.text = textConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
const responsesToolChoice = convertOpenAiToolChoiceToResponses(openaiBody.tool_choice, toolNameMap);
|
const responsesToolChoice = openaiBody.tool_choice !== undefined
|
||||||
|
? convertOpenAiToolChoiceToResponses(openaiBody.tool_choice, toolNameMap)
|
||||||
|
: convertOpenAiFunctionCallToResponsesToolChoice(openaiBody.function_call, toolNameMap);
|
||||||
if (responsesToolChoice !== undefined) body.tool_choice = responsesToolChoice;
|
if (responsesToolChoice !== undefined) body.tool_choice = responsesToolChoice;
|
||||||
if (Array.isArray(body.tools) && body.tools.length === 0) {
|
if (Array.isArray(body.tools) && body.tools.length === 0) {
|
||||||
delete body.tool_choice;
|
delete body.tool_choice;
|
||||||
|
|||||||
@@ -973,11 +973,25 @@ function convertClaudeToolsToOpenAiChat(rawTools: unknown): unknown {
|
|||||||
if (!isRecord(item)) return item;
|
if (!isRecord(item)) return item;
|
||||||
|
|
||||||
const type = asTrimmedString(item.type).toLowerCase();
|
const type = asTrimmedString(item.type).toLowerCase();
|
||||||
|
const name = asTrimmedString(item.name);
|
||||||
|
if (
|
||||||
|
type === 'web_search'
|
||||||
|
|| type === 'web_search_20250305'
|
||||||
|
|| type === 'google_search'
|
||||||
|
|| name === 'web_search'
|
||||||
|
|| name === 'google_search'
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
type: 'web_search',
|
||||||
|
name: 'web_search',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (type === 'function' || type === 'custom' || type === 'image_generation') {
|
if (type === 'function' || type === 'custom' || type === 'image_generation') {
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = asTrimmedString(item.name);
|
|
||||||
if (!name) return item;
|
if (!name) return item;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -476,13 +476,9 @@ describe('UpdateCenterSection', () => {
|
|||||||
});
|
});
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
|
||||||
const recentTagCard = root.root.find((node) => (
|
|
||||||
typeof node.props?.style === 'object'
|
|
||||||
&& collectText(node).includes('dev-20260417-f67ade2 @ sha256:bbbbbbbbbbbb')
|
|
||||||
));
|
|
||||||
expect(recentTagCard).toBeTruthy();
|
|
||||||
expect(collectText(root.root)).toContain('Digest:sha256:bbbbbbbbbbbb');
|
expect(collectText(root.root)).toContain('Digest:sha256:bbbbbbbbbbbb');
|
||||||
expect(collectText(root.root)).not.toContain('feature-without-raw-tag');
|
expect(collectText(root.root)).not.toContain('feature-without-raw-tag');
|
||||||
|
expect(collectText(root.root)).toContain('dev-20260417-f67ade2 @ sha256:bbbbbbbbbbbb');
|
||||||
|
|
||||||
const deployRecentButton = root.root.find((node) => (
|
const deployRecentButton = root.root.find((node) => (
|
||||||
node.type === 'button'
|
node.type === 'button'
|
||||||
@@ -508,6 +504,111 @@ describe('UpdateCenterSection', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('disables only the recent Docker candidate that already matches the running helper image', async () => {
|
||||||
|
apiMock.getUpdateCenterStatus.mockResolvedValueOnce({
|
||||||
|
currentVersion: '1.2.3',
|
||||||
|
config: {
|
||||||
|
enabled: true,
|
||||||
|
helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850',
|
||||||
|
namespace: 'ai',
|
||||||
|
releaseName: 'metapi',
|
||||||
|
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||||
|
imageRepository: '1467078763/metapi',
|
||||||
|
githubReleasesEnabled: true,
|
||||||
|
dockerHubTagsEnabled: true,
|
||||||
|
defaultDeploySource: 'docker-hub-tag',
|
||||||
|
},
|
||||||
|
githubRelease: {
|
||||||
|
normalizedVersion: '1.3.0',
|
||||||
|
displayVersion: '1.3.0',
|
||||||
|
},
|
||||||
|
dockerHubTag: {
|
||||||
|
normalizedVersion: 'latest',
|
||||||
|
tagName: 'latest',
|
||||||
|
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||||
|
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||||
|
},
|
||||||
|
dockerHubRecentTags: [
|
||||||
|
{
|
||||||
|
normalizedVersion: 'dev',
|
||||||
|
tagName: 'dev',
|
||||||
|
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||||
|
displayVersion: 'dev @ sha256:aaaaaaaaaaaa',
|
||||||
|
publishedAt: '2026-03-30T11:54:35.591877Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
normalizedVersion: 'dev-20260417-next',
|
||||||
|
tagName: 'dev-20260417-next',
|
||||||
|
digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||||
|
displayVersion: 'dev-20260417-next @ sha256:bbbbbbbbbbbb',
|
||||||
|
publishedAt: '2026-03-30T10:54:35.591877Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
helper: {
|
||||||
|
ok: true,
|
||||||
|
healthy: true,
|
||||||
|
revision: '17',
|
||||||
|
imageTag: 'dev',
|
||||||
|
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||||
|
history: [],
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
lastCheckedAt: '2026-03-30 20:30:00',
|
||||||
|
lastCheckError: null,
|
||||||
|
lastResolvedSource: 'docker-hub-tag',
|
||||||
|
lastResolvedDisplayVersion: 'dev',
|
||||||
|
lastResolvedCandidateKey: 'docker-hub-tag:dev@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||||
|
lastNotifiedCandidateKey: null,
|
||||||
|
lastNotifiedAt: null,
|
||||||
|
},
|
||||||
|
runningTask: null,
|
||||||
|
lastFinishedTask: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
let root!: ReactTestRenderer;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ToastProvider>
|
||||||
|
<UpdateCenterSection />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const runningCandidateButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node) === '部署 dev'
|
||||||
|
));
|
||||||
|
const nextCandidateButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node) === '部署 dev-20260417-next'
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(runningCandidateButton.props.disabled).toBe(true);
|
||||||
|
expect(runningCandidateButton.props.title).toContain('当前已运行该镜像');
|
||||||
|
expect(nextCandidateButton.props.disabled).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await nextCandidateButton.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(apiMock.deployUpdateCenter).toHaveBeenCalledWith({
|
||||||
|
source: 'docker-hub-tag',
|
||||||
|
targetTag: 'dev-20260417-next',
|
||||||
|
targetDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||||
|
});
|
||||||
|
expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-1', expect.any(Object));
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps rollback history compact and opens the full revision list in a centered modal', async () => {
|
it('keeps rollback history compact and opens the full revision list in a centered modal', async () => {
|
||||||
let root!: ReactTestRenderer;
|
let root!: ReactTestRenderer;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -451,10 +451,6 @@ export default function UpdateCenterSection() {
|
|||||||
const manualDockerTag = String(manualDockerTarget.tag || '').trim();
|
const manualDockerTag = String(manualDockerTarget.tag || '').trim();
|
||||||
const manualDockerDigest = String(manualDockerTarget.digest || '').trim();
|
const manualDockerDigest = String(manualDockerTarget.digest || '').trim();
|
||||||
const recentDockerCandidates = normalizeRecentDockerCandidates(status?.dockerHubRecentTags);
|
const recentDockerCandidates = normalizeRecentDockerCandidates(status?.dockerHubRecentTags);
|
||||||
const canDeployDockerByTag = !deploying
|
|
||||||
&& config.enabled
|
|
||||||
&& config.dockerHubTagsEnabled
|
|
||||||
&& helperHealthy;
|
|
||||||
const canDeployManualDocker = !deploying
|
const canDeployManualDocker = !deploying
|
||||||
&& config.enabled
|
&& config.enabled
|
||||||
&& config.dockerHubTagsEnabled
|
&& config.dockerHubTagsEnabled
|
||||||
@@ -809,6 +805,15 @@ export default function UpdateCenterSection() {
|
|||||||
const candidateTag = String(candidate.tagName || '').trim();
|
const candidateTag = String(candidate.tagName || '').trim();
|
||||||
const candidateDigest = String(candidate.digest || '').trim();
|
const candidateDigest = String(candidate.digest || '').trim();
|
||||||
const candidateLabel = candidate.displayVersion || candidate.normalizedVersion || candidateTag;
|
const candidateLabel = candidate.displayVersion || candidate.normalizedVersion || candidateTag;
|
||||||
|
const candidateDeployState = describeDockerDeployState({
|
||||||
|
enabled: config.enabled && config.dockerHubTagsEnabled,
|
||||||
|
helperHealthy,
|
||||||
|
helperError: status?.helper?.error,
|
||||||
|
currentVersion: status?.currentVersion,
|
||||||
|
helper: status?.helper,
|
||||||
|
candidate,
|
||||||
|
});
|
||||||
|
const canDeployCandidate = !deploying && candidateDeployState.canDeploy;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${candidateTag}:${candidateDigest || 'no-digest'}`}
|
key={`${candidateTag}:${candidateDigest || 'no-digest'}`}
|
||||||
@@ -836,9 +841,10 @@ export default function UpdateCenterSection() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
style={{ border: '1px solid var(--color-border)' }}
|
style={{ border: '1px solid var(--color-border)' }}
|
||||||
disabled={!canDeployDockerByTag || !candidateTag}
|
disabled={!canDeployCandidate || !candidateTag}
|
||||||
|
title={candidateDeployState.reason}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!canDeployDockerByTag || !candidateTag) return;
|
if (!canDeployCandidate || !candidateTag) return;
|
||||||
void runDeploy('docker-hub-tag', {
|
void runDeploy('docker-hub-tag', {
|
||||||
tag: candidateTag,
|
tag: candidateTag,
|
||||||
digest: candidateDigest || null,
|
digest: candidateDigest || null,
|
||||||
|
|||||||
Reference in New Issue
Block a user