Carry protocol review fixes into release

The release branch is main-based and tracks the reviewed dev slice for PR publication. This merge carries the post-review compatibility fixes without rewriting the already-open PR history.

Constraint: PR #533 publishes dev changes through a clean release branch targeting main.

Rejected: Recreate the PR branch from scratch | unnecessary churn after green checks and review context already exist.

Confidence: high

Scope-risk: narrow

Tested: npx vitest run --root . src/server/proxy-core/responsesPreflight.test.ts src/server/proxy-core/serviceTierPolicy.test.ts src/server/routes/proxy/chat.stream.test.ts src/web/pages/settings/UpdateCenterSection.test.tsx

Tested: npm run typecheck:server

Tested: npm run typecheck:web

Tested: npm run repo:drift-check

Co-authored-by: OmX <omx@oh-my-codex.dev>
This commit is contained in:
cita-777
2026-05-08 10:43:12 +08:00
9 changed files with 163 additions and 19 deletions
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { validateExternalResponsesHttpRequest } from './responsesPreflight.js'; import {
hasResponsesWebSearchOnlyRequest,
validateExternalResponsesHttpRequest,
} from './responsesPreflight.js';
describe('validateExternalResponsesHttpRequest', () => { describe('validateExternalResponsesHttpRequest', () => {
it('rejects external HTTP previous_response_id and explains msg ids separately', () => { it('rejects external HTTP previous_response_id and explains msg ids separately', () => {
@@ -63,3 +66,21 @@ describe('validateExternalResponsesHttpRequest', () => {
}); });
}); });
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);
});
});
@@ -120,6 +120,7 @@ function isWebSearchToolRecord(tool: Record<string, unknown>): boolean {
const name = asTrimmedString(tool.name).toLowerCase(); const name = asTrimmedString(tool.name).toLowerCase();
return type === 'web_search' return type === 'web_search'
|| type === 'web_search_preview' || type === 'web_search_preview'
|| type === 'web_search_preview_2025_03_11'
|| type === 'web_search_20250305' || type === 'web_search_20250305'
|| type === 'google_search' || type === 'google_search'
|| name === 'web_search' || name === 'web_search'
@@ -3,7 +3,15 @@ import { describe, expect, it } from 'vitest';
import { applyOpenAiServiceTierPolicy } from './serviceTierPolicy.js'; import { applyOpenAiServiceTierPolicy } from './serviceTierPolicy.js';
describe('applyOpenAiServiceTierPolicy', () => { describe('applyOpenAiServiceTierPolicy', () => {
it('normalizes fast to priority and drops unknown or non-string tiers', () => { 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({ expect(applyOpenAiServiceTierPolicy({
body: { model: 'gpt-5', service_tier: ' fast ' }, body: { model: 'gpt-5', service_tier: ' fast ' },
})).toMatchObject({ })).toMatchObject({
@@ -73,4 +81,3 @@ describe('applyOpenAiServiceTierPolicy', () => {
} }
}); });
}); });
+2 -2
View File
@@ -126,6 +126,7 @@ export function applyOpenAiServiceTierPolicy(input: {
context?: ServiceTierRuleContext; context?: ServiceTierRuleContext;
rules?: unknown; rules?: unknown;
}): ServiceTierPolicyResult { }): ServiceTierPolicyResult {
const hasRequestedServiceTier = Object.prototype.hasOwnProperty.call(input.body, 'service_tier');
const rawTier = input.body.service_tier; const rawTier = input.body.service_tier;
const serviceTier = normalizeServiceTier(rawTier); const serviceTier = normalizeServiceTier(rawTier);
const next = { ...input.body }; const next = { ...input.body };
@@ -135,7 +136,7 @@ export function applyOpenAiServiceTierPolicy(input: {
return { return {
ok: true, ok: true,
body: next, body: next,
action: 'filter', action: hasRequestedServiceTier ? 'filter' : 'pass',
}; };
} }
@@ -160,4 +161,3 @@ export function applyOpenAiServiceTierPolicy(input: {
action: 'pass', action: 'pass',
}; };
} }
+8 -5
View File
@@ -14,6 +14,8 @@ function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''; return typeof value === 'string' ? value.trim() : '';
} }
const SEARCH_SIMULATION_MODEL = '__search';
function findSearchTool(body: Record<string, unknown>): Record<string, unknown> | null { function findSearchTool(body: Record<string, unknown>): Record<string, unknown> | null {
const tools = Array.isArray(body.tools) ? body.tools : []; const tools = Array.isArray(body.tools) ? body.tools : [];
for (const tool of tools) { for (const tool of tools) {
@@ -23,6 +25,7 @@ function findSearchTool(body: Record<string, unknown>): Record<string, unknown>
if ( if (
type === 'web_search' type === 'web_search'
|| type === 'web_search_preview' || type === 'web_search_preview'
|| type === 'web_search_preview_2025_03_11'
|| type === 'web_search_20250305' || type === 'web_search_20250305'
|| type === 'google_search' || type === 'google_search'
|| name === 'web_search' || name === 'web_search'
@@ -129,6 +132,7 @@ function buildSyntheticResponsesPayload(input: {
const responseId = `resp_web_search_${randomUUID()}`; const responseId = `resp_web_search_${randomUUID()}`;
const searchCallId = `ws_${randomUUID()}`; const searchCallId = `ws_${randomUUID()}`;
const results = normalizeSearchResults(input.searchPayload); const results = normalizeSearchResults(input.searchPayload);
const outputText = results.length > 0 ? JSON.stringify(results) : '[]';
return { return {
id: responseId, id: responseId,
@@ -136,6 +140,7 @@ function buildSyntheticResponsesPayload(input: {
created_at: createdAt, created_at: createdAt,
model: asTrimmedString(input.body.model) || 'unknown', model: asTrimmedString(input.body.model) || 'unknown',
status: 'completed', status: 'completed',
output_text: outputText,
output: [ output: [
{ {
id: searchCallId, id: searchCallId,
@@ -153,9 +158,7 @@ function buildSyntheticResponsesPayload(input: {
status: 'completed', status: 'completed',
content: [{ content: [{
type: 'output_text', type: 'output_text',
text: results.length > 0 text: outputText,
? JSON.stringify(results)
: '[]',
}], }],
}, },
], ],
@@ -193,7 +196,7 @@ async function sendAnthropicSearchSimulation(input: {
app: input.app, app: input.app,
request: input.request, request: input.request,
query, query,
model: asTrimmedString(input.body.model) || asTrimmedString(input.openAiBody.model) || '__search', model: SEARCH_SIMULATION_MODEL,
maxResults: toSearchMaxResults(tool), maxResults: toSearchMaxResults(tool),
}); });
if (search.statusCode < 200 || search.statusCode >= 300) { if (search.statusCode < 200 || search.statusCode >= 300) {
@@ -252,7 +255,7 @@ async function sendResponsesSearchSimulation(input: {
app: input.app, app: input.app,
request: input.request, request: input.request,
query, query,
model: asTrimmedString(input.body.model) || '__search', model: SEARCH_SIMULATION_MODEL,
maxResults: toSearchMaxResults(tool), maxResults: toSearchMaxResults(tool),
}); });
if (search.statusCode < 200 || search.statusCode >= 300) { if (search.statusCode < 200 || search.statusCode >= 300) {
@@ -4749,6 +4749,7 @@ describe('chat proxy stream behavior', () => {
expect(response.statusCode, response.body).toBe(200); expect(response.statusCode, response.body).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledTimes(1);
expect(selectChannelMock).toHaveBeenCalledWith('__search', expect.anything());
const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any]; const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
expect(targetUrl).toBe('https://upstream.example.com/v1/search'); expect(targetUrl).toBe('https://upstream.example.com/v1/search');
expect(JSON.parse(options.body)).toMatchObject({ expect(JSON.parse(options.body)).toMatchObject({
@@ -4816,6 +4817,7 @@ describe('chat proxy stream behavior', () => {
expect(response.statusCode, response.body).toBe(200); expect(response.statusCode, response.body).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledTimes(1);
expect(selectChannelMock).toHaveBeenCalledWith('__search', expect.anything());
const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any]; const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
expect(targetUrl).toBe('https://upstream.example.com/v1/search'); expect(targetUrl).toBe('https://upstream.example.com/v1/search');
expect(JSON.parse(options.body)).toMatchObject({ expect(JSON.parse(options.body)).toMatchObject({
@@ -4825,6 +4827,7 @@ describe('chat proxy stream behavior', () => {
const body = response.json(); const body = response.json();
expect(body.object).toBe('response'); expect(body.object).toBe('response');
expect(body.output_text).toContain('Metapi Responses');
expect(body.output?.[0]).toMatchObject({ expect(body.output?.[0]).toMatchObject({
type: 'web_search_call', type: 'web_search_call',
status: 'completed', status: 'completed',
@@ -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);
@@ -500,6 +500,110 @@ 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',
});
} 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 {
+12 -6
View File
@@ -445,10 +445,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
@@ -803,6 +799,15 @@ export default function UpdateCenterSection() {
const candidateTag = String(candidate.tagName || candidate.normalizedVersion || '').trim(); const candidateTag = String(candidate.tagName || candidate.normalizedVersion || '').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'}`}
@@ -825,9 +830,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,