icebear icebear0828 commited on
Commit
6fa846e
ยท
unverified ยท
1 Parent(s): 8991383

fix: make instructions optional in /v1/responses for client compatibility (#71) (#112)

Browse files

Cherry and other third-party clients don't send the `instructions` field.
Previously this returned 400; now it defaults to an empty string.

Co-authored-by: icebear0828 <icebear0828@users.noreply.github.com>

CHANGELOG.md CHANGED
@@ -6,6 +6,11 @@
6
 
7
  ## [Unreleased]
8
 
 
 
 
 
 
9
  ### Added
10
 
11
  - Sticky rotation strategy๏ผˆ#107๏ผ‰๏ผšๆ–ฐๅขž `sticky` ่ดฆๅท่ฝฎๆข็ญ–็•ฅ๏ผŒๆŒ็ปญไฝฟ็”จๅŒไธ€่ดฆๅท็›ดๅˆฐ้™้€Ÿๆˆ–้ขๅบฆ่€—ๅฐฝ
 
6
 
7
  ## [Unreleased]
8
 
9
+ ### Fixed
10
+
11
+ - `/v1/responses` ไธๅ†ๅผบๅˆถ่ฆๆฑ‚ `instructions` ๅญ—ๆฎต๏ผŒๆœชไผ ๆ—ถ้ป˜่ฎค็ฉบๅญ—็ฌฆไธฒ๏ผˆ#71๏ผ‰
12
+ - ไฟฎๅค Cherry ็ญ‰็ฌฌไธ‰ๆ–นๅฎขๆˆท็ซฏไธไผ  `instructions` ๆ—ถ่ฟ”ๅ›ž 400 ็š„ๅ…ผๅฎนๆ€ง้—ฎ้ข˜
13
+
14
  ### Added
15
 
16
  - Sticky rotation strategy๏ผˆ#107๏ผ‰๏ผšๆ–ฐๅขž `sticky` ่ดฆๅท่ฝฎๆข็ญ–็•ฅ๏ผŒๆŒ็ปญไฝฟ็”จๅŒไธ€่ดฆๅท็›ดๅˆฐ้™้€Ÿๆˆ–้ขๅบฆ่€—ๅฐฝ
src/proxy/codex-api.ts CHANGED
@@ -23,7 +23,7 @@ let _firstModelFetchLogged = false;
23
 
24
  export interface CodexResponsesRequest {
25
  model: string;
26
- instructions: string;
27
  input: CodexInputItem[];
28
  stream: true;
29
  store: false;
 
23
 
24
  export interface CodexResponsesRequest {
25
  model: string;
26
+ instructions?: string | null;
27
  input: CodexInputItem[];
28
  stream: true;
29
  store: false;
src/routes/__tests__/responses-optional-instructions.test.ts ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Tests that /v1/responses works without the `instructions` field.
3
+ * Regression test for: https://github.com/icebear0828/codex-proxy/issues/71
4
+ */
5
+
6
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
7
+ import { Hono } from "hono";
8
+
9
+ // โ”€โ”€ Mocks (before imports) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
10
+
11
+ const mockConfig = {
12
+ server: { proxy_api_key: null as string | null },
13
+ model: {
14
+ default: "gpt-5.2-codex",
15
+ default_reasoning_effort: null,
16
+ default_service_tier: null,
17
+ suppress_desktop_directives: false,
18
+ },
19
+ auth: {
20
+ jwt_token: undefined as string | undefined,
21
+ rotation_strategy: "least_used" as const,
22
+ rate_limit_backoff_seconds: 60,
23
+ },
24
+ };
25
+
26
+ vi.mock("../../config.js", () => ({
27
+ getConfig: vi.fn(() => mockConfig),
28
+ }));
29
+
30
+ vi.mock("../../paths.js", () => ({
31
+ getDataDir: vi.fn(() => "/tmp/test-responses"),
32
+ getConfigDir: vi.fn(() => "/tmp/test-responses-config"),
33
+ }));
34
+
35
+ vi.mock("fs", async (importOriginal) => {
36
+ const actual = await importOriginal<typeof import("fs")>();
37
+ return {
38
+ ...actual,
39
+ readFileSync: vi.fn(() => "models: []"),
40
+ writeFileSync: vi.fn(),
41
+ writeFile: vi.fn(
42
+ (_p: string, _d: string, _e: string, cb: (err: Error | null) => void) =>
43
+ cb(null),
44
+ ),
45
+ existsSync: vi.fn(() => false),
46
+ mkdirSync: vi.fn(),
47
+ renameSync: vi.fn(),
48
+ };
49
+ });
50
+
51
+ vi.mock("js-yaml", () => ({
52
+ default: {
53
+ load: vi.fn(() => ({ models: [], aliases: {} })),
54
+ dump: vi.fn(() => ""),
55
+ },
56
+ }));
57
+
58
+ vi.mock("../../auth/jwt-utils.js", () => ({
59
+ decodeJwtPayload: vi.fn(() => ({
60
+ exp: Math.floor(Date.now() / 1000) + 3600,
61
+ })),
62
+ extractChatGptAccountId: vi.fn((token: string) => `acct-${token}`),
63
+ extractUserProfile: vi.fn(() => null),
64
+ isTokenExpired: vi.fn(() => false),
65
+ }));
66
+
67
+ vi.mock("../../models/model-fetcher.js", () => ({
68
+ triggerImmediateRefresh: vi.fn(),
69
+ startModelRefresh: vi.fn(),
70
+ stopModelRefresh: vi.fn(),
71
+ }));
72
+
73
+ vi.mock("../../utils/retry.js", () => ({
74
+ withRetry: vi.fn(async (fn: () => Promise<unknown>) => fn()),
75
+ }));
76
+
77
+ // Capture the codexRequest that handleProxyRequest receives
78
+ let capturedCodexRequest: unknown = null;
79
+
80
+ vi.mock("../shared/proxy-handler.js", () => ({
81
+ handleProxyRequest: vi.fn(async (c, _pool, _jar, proxyReq) => {
82
+ capturedCodexRequest = proxyReq.codexRequest;
83
+ return c.json({ ok: true });
84
+ }),
85
+ }));
86
+
87
+ // โ”€โ”€ Imports โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
88
+
89
+ import { AccountPool } from "../../auth/account-pool.js";
90
+ import { loadStaticModels } from "../../models/model-store.js";
91
+ import { createResponsesRoutes } from "../responses.js";
92
+
93
+ // โ”€โ”€ Tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
94
+
95
+ describe("/v1/responses โ€” optional instructions", () => {
96
+ let pool: AccountPool;
97
+ let app: Hono;
98
+
99
+ beforeEach(() => {
100
+ vi.clearAllMocks();
101
+ capturedCodexRequest = null;
102
+ mockConfig.server.proxy_api_key = null;
103
+ loadStaticModels();
104
+ pool = new AccountPool();
105
+ pool.addAccount("test-token-1");
106
+ app = createResponsesRoutes(pool);
107
+ });
108
+
109
+ afterEach(() => {
110
+ pool?.destroy();
111
+ });
112
+
113
+ it("accepts request without instructions field", async () => {
114
+ const res = await app.request("/v1/responses", {
115
+ method: "POST",
116
+ headers: { "Content-Type": "application/json" },
117
+ body: JSON.stringify({
118
+ model: "codex",
119
+ input: [{ role: "user", content: "Hello" }],
120
+ stream: true,
121
+ }),
122
+ });
123
+
124
+ expect(res.status).toBe(200);
125
+ });
126
+
127
+ it("accepts request with instructions: null", async () => {
128
+ const res = await app.request("/v1/responses", {
129
+ method: "POST",
130
+ headers: { "Content-Type": "application/json" },
131
+ body: JSON.stringify({
132
+ model: "codex",
133
+ instructions: null,
134
+ input: [{ role: "user", content: "Hello" }],
135
+ stream: true,
136
+ }),
137
+ });
138
+
139
+ expect(res.status).toBe(200);
140
+ });
141
+
142
+ it("defaults instructions to empty string when omitted", async () => {
143
+ await app.request("/v1/responses", {
144
+ method: "POST",
145
+ headers: { "Content-Type": "application/json" },
146
+ body: JSON.stringify({
147
+ model: "codex",
148
+ input: [{ role: "user", content: "Hello" }],
149
+ stream: true,
150
+ }),
151
+ });
152
+
153
+ expect(capturedCodexRequest).toBeDefined();
154
+ const req = capturedCodexRequest as Record<string, unknown>;
155
+ expect(req.instructions).toBe("");
156
+ });
157
+
158
+ it("preserves instructions when provided as string", async () => {
159
+ await app.request("/v1/responses", {
160
+ method: "POST",
161
+ headers: { "Content-Type": "application/json" },
162
+ body: JSON.stringify({
163
+ model: "codex",
164
+ instructions: "You are a helpful assistant.",
165
+ input: [{ role: "user", content: "Hello" }],
166
+ stream: true,
167
+ }),
168
+ });
169
+
170
+ expect(capturedCodexRequest).toBeDefined();
171
+ const req = capturedCodexRequest as Record<string, unknown>;
172
+ expect(req.instructions).toBe("You are a helpful assistant.");
173
+ });
174
+
175
+ it("still rejects non-object body", async () => {
176
+ const res = await app.request("/v1/responses", {
177
+ method: "POST",
178
+ headers: { "Content-Type": "application/json" },
179
+ body: JSON.stringify("not an object"),
180
+ });
181
+
182
+ expect(res.status).toBe(400);
183
+ });
184
+ });
src/routes/responses.ts CHANGED
@@ -274,14 +274,14 @@ export function createResponsesRoutes(
274
  });
275
  }
276
 
277
- if (!isRecord(body) || typeof body.instructions !== "string") {
278
  c.status(400);
279
  return c.json({
280
  type: "error",
281
  error: {
282
  type: "invalid_request_error",
283
  code: "invalid_request",
284
- message: "Missing required field: instructions (string)",
285
  },
286
  });
287
  }
@@ -298,7 +298,7 @@ export function createResponsesRoutes(
298
  // When client sends stream:false, the proxy collects SSE events and returns assembled JSON.
299
  const codexRequest: CodexResponsesRequest = {
300
  model: modelId,
301
- instructions: body.instructions,
302
  input: Array.isArray(body.input) ? (body.input as CodexInputItem[]) : [],
303
  stream: true,
304
  store: false,
 
274
  });
275
  }
276
 
277
+ if (!isRecord(body)) {
278
  c.status(400);
279
  return c.json({
280
  type: "error",
281
  error: {
282
  type: "invalid_request_error",
283
  code: "invalid_request",
284
+ message: "Request body must be a JSON object",
285
  },
286
  });
287
  }
 
298
  // When client sends stream:false, the proxy collects SSE events and returns assembled JSON.
299
  const codexRequest: CodexResponsesRequest = {
300
  model: modelId,
301
+ instructions: typeof body.instructions === "string" ? body.instructions : "",
302
  input: Array.isArray(body.input) ? (body.input as CodexInputItem[]) : [],
303
  stream: true,
304
  store: false,