@cartanova/qgrid-cli 2.9.5 → 2.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/bundle/dist/application/qgrid/qgrid-response-format.js +7 -7
  2. package/bundle/dist/application/qgrid/qgrid.types.js +14 -1
  3. package/bundle/dist/application/qgrid/tool-emulation.js +2 -1
  4. package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +4 -2
  5. package/bundle/dist/utils/providers/common/model-cost.js +18 -1
  6. package/bundle/dist/utils/providers/openai/openai-backend-protocol.js +22 -3
  7. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +7 -2
  8. package/bundle/dist/utils/providers/openai/openai-image-generation.js +4 -3
  9. package/bundle/src/application/qgrid/qgrid-response-format.test.ts +6 -1
  10. package/bundle/src/application/qgrid/qgrid-response-format.ts +8 -6
  11. package/bundle/src/application/qgrid/qgrid.types.ts +16 -6
  12. package/bundle/src/application/qgrid/tool-emulation.test.ts +7 -0
  13. package/bundle/src/application/qgrid/tool-emulation.ts +3 -1
  14. package/bundle/src/utils/providers/anthropic/anthropic-constants.test.ts +5 -1
  15. package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +9 -2
  16. package/bundle/src/utils/providers/common/effort.test.ts +3 -0
  17. package/bundle/src/utils/providers/common/model-cost.test.ts +22 -0
  18. package/bundle/src/utils/providers/common/model-cost.ts +25 -2
  19. package/bundle/src/utils/providers/common/provider-dispatcher.ts +3 -1
  20. package/bundle/src/utils/providers/openai/openai-backend-protocol.test.ts +23 -0
  21. package/bundle/src/utils/providers/openai/openai-backend-protocol.ts +19 -2
  22. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +22 -0
  23. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +10 -1
  24. package/bundle/src/utils/providers/openai/openai-image-generation.test.ts +17 -0
  25. package/bundle/src/utils/providers/openai/openai-image-generation.ts +7 -1
  26. package/bundle/web-dist/client/assets/{index-BcCytvFf.js → index-D4MEKFjK.js} +3 -3
  27. package/bundle/web-dist/client/assets/{logs-BkOilk5x.js → logs-BWhH8t7Y.js} +1 -1
  28. package/bundle/web-dist/client/assets/{monit-8oCSLCdL.js → monit-7tcWgB20.js} +1 -1
  29. package/bundle/web-dist/client/assets/{routes-DFIF4IoW.js → routes-CSVp9aNn.js} +1 -1
  30. package/bundle/web-dist/client/assets/{settings-nUI-DR3U.js → settings-CYgR0YTh.js} +1 -1
  31. package/bundle/web-dist/client/assets/{show-Bzx6Xkoc.js → show-Cg8n_Mxg.js} +1 -1
  32. package/bundle/web-dist/client/assets/{tokens-DFWCL7Ts.js → tokens-B5yg6bP1.js} +1 -1
  33. package/bundle/web-dist/client/assets/{use-oauth-login-flow-B5MAjpVc.js → use-oauth-login-flow-DTrmdduV.js} +1 -1
  34. package/bundle/web-dist/client/index.html +1 -1
  35. package/bundle/web-dist/server/entry-server.generated.js +4 -1
  36. package/package.json +1 -1
  37. package/skills/qgrid/SKILL.md +7 -0
  38. package/skills/qgrid/references/ai-sdk-provider-contract.md +4 -2
  39. package/skills/qgrid/references/anthropic-claude-code-runtime.md +3 -2
  40. package/skills/qgrid/references/openai-codex-runtime.md +20 -5
  41. package/skills/qgrid/references/provider-runtime-differences.md +1 -1
  42. package/skills/qgrid/references/sonamu-api-web-flow.md +1 -1
@@ -33,6 +33,7 @@ describe("Anthropic 1M context policy", () => {
33
33
  expect(supports1MContext("claude-fable-5-1")).toBe(true);
34
34
  expect(supports1MContext("claude-fable-5")).toBe(true);
35
35
  expect(supports1MContext("claude-opus-5")).toBe(true);
36
+ expect(supports1MContext("claude-opus-5-5")).toBe(true);
36
37
  expect(supports1MContext("claude-sonnet-5")).toBe(true);
37
38
  expect(supports1MContext("claude-sonnet-4-6")).toBe(true);
38
39
  expect(supports1MContext("claude-opus-4-6")).toBe(true);
@@ -48,6 +49,7 @@ describe("Anthropic 1M context policy", () => {
48
49
  expect(needsCli1mSuffix("claude-fable-5-1")).toBe(false);
49
50
  expect(needsCli1mSuffix("claude-fable-5")).toBe(false);
50
51
  expect(needsCli1mSuffix("claude-opus-5")).toBe(false);
52
+ expect(needsCli1mSuffix("claude-opus-5-5")).toBe(false);
51
53
  expect(needsCli1mSuffix("claude-sonnet-5")).toBe(false);
52
54
  expect(needsCli1mSuffix("claude-sonnet-4-6")).toBe(true);
53
55
  expect(needsCli1mSuffix("claude-opus-4-6")).toBe(true);
@@ -78,13 +80,15 @@ describe("Anthropic 1M context policy", () => {
78
80
  });
79
81
 
80
82
  describe("Anthropic thinking policy", () => {
81
- it("Fable 5/5.1 과 Opus 5 는 adaptive thinking 을 보존한다", () => {
83
+ it("Fable 5/5.1, Opus 5, Opus 5.5 는 adaptive thinking 을 보존한다", () => {
82
84
  expect(usesAdaptiveThinking("claude-fable-5-1")).toBe(true);
83
85
  expect(usesAdaptiveThinking("anthropic/claude-fable-5-1")).toBe(true);
84
86
  expect(usesAdaptiveThinking("claude-fable-5")).toBe(true);
85
87
  expect(usesAdaptiveThinking("anthropic/claude-fable-5")).toBe(true);
86
88
  expect(usesAdaptiveThinking("claude-opus-5")).toBe(true);
87
89
  expect(usesAdaptiveThinking("anthropic/claude-opus-5")).toBe(true);
90
+ expect(usesAdaptiveThinking("claude-opus-5-5")).toBe(true);
91
+ expect(usesAdaptiveThinking("anthropic/claude-opus-5-5[1m]")).toBe(true);
88
92
  expect(usesAdaptiveThinking("claude-sonnet-5")).toBe(false);
89
93
  expect(usesAdaptiveThinking("claude-opus-4-8")).toBe(false);
90
94
  });
@@ -48,6 +48,7 @@ const ONE_MILLION_CONTEXT_MODELS = new Set([
48
48
  "claude-fable-5-1",
49
49
  "claude-fable-5",
50
50
  "claude-opus-5",
51
+ "claude-opus-5-5",
51
52
  "claude-sonnet-5",
52
53
  "claude-sonnet-4-6",
53
54
  "claude-opus-4-6",
@@ -55,11 +56,17 @@ const ONE_MILLION_CONTEXT_MODELS = new Set([
55
56
  ]);
56
57
 
57
58
  const CLI_ONE_MILLION_SUFFIX_MODELS = new Set(["claude-sonnet-4-6", "claude-opus-4-6"]);
58
- // Fable 5/5.1 은 adaptive thinking 이 유일한 모드다(CLI 카탈로그 `rejects_disabled_thinking`). Opus 5 는
59
+ // Fable 5/5.1 과 Opus 5.5 는 adaptive thinking 이 유일한 모드다(Fable: CLI 카탈로그 `rejects_disabled_thinking`,
60
+ // Opus 5.5: 공식 사양 "can't be turned off", 2026-09-22 출시). Opus 5 는
59
61
  // disabled 를 high 이하에서 지원하지만, 공식 기본값·권장 동작인 adaptive thinking 을 유지한다. qgrid
60
62
  // 기본 effort=low 가 비용/지연 제어를 맡으며, 이 정책은 disabled+xhigh/max 400 오류와 disabled 시
61
63
  // tool/XML 출력 오염도 피한다.
62
- const ADAPTIVE_THINKING_MODELS = new Set(["claude-fable-5-1", "claude-fable-5", "claude-opus-5"]);
64
+ const ADAPTIVE_THINKING_MODELS = new Set([
65
+ "claude-fable-5-1",
66
+ "claude-fable-5",
67
+ "claude-opus-5",
68
+ "claude-opus-5-5",
69
+ ]);
63
70
 
64
71
  // Fable 계열: 구독 플랜에서 usage credits 가 필요한 최상위 티어. 구독 usage API 의
65
72
  // `seven_day_overage_included` 버킷은 이 계열에만 적용되므로 quota threshold 판정이 참조한다.
@@ -23,6 +23,8 @@ describe("resolveOpenAIEffort", () => {
23
23
  expect(resolveOpenAIEffort("gpt-5.6-terra", "max")).toBe("max");
24
24
  expect(resolveOpenAIEffort("gpt-5.6-terra", "ultra")).toBe("ultra");
25
25
  expect(resolveOpenAIEffort("gpt-5.6-luna", "max")).toBe("max");
26
+ expect(resolveOpenAIEffort("gpt-6-sol", "ultra")).toBe("ultra");
27
+ expect(resolveOpenAIEffort("gpt-6-luna", "max")).toBe("max");
26
28
  });
27
29
 
28
30
  it("공개 API 어휘(none/minimal)와 다른 provider 어휘, 오타는 조용히 미지정으로 바꾼다", () => {
@@ -36,6 +38,7 @@ describe("resolveOpenAIEffort", () => {
36
38
  expect(resolveOpenAIEffort("gpt-5.5", "max")).toBeUndefined();
37
39
  expect(resolveOpenAIEffort("gpt-5.4", "ultra")).toBeUndefined();
38
40
  expect(resolveOpenAIEffort("gpt-5.6-luna", "ultra")).toBeUndefined();
41
+ expect(resolveOpenAIEffort("gpt-6-luna", "ultra")).toBeUndefined();
39
42
  expect(resolveOpenAIEffort("gpt-5.3-codex-spark", "xhigh")).toBe("xhigh");
40
43
  });
41
44
 
@@ -13,6 +13,8 @@ describe("calculateCostUsd", () => {
13
13
  });
14
14
  it.each([
15
15
  ["gpt-6-astra", 10, 50, 1, 12.5],
16
+ ["gpt-6-sol", 2, 10, 0.2, 2.5],
17
+ ["gpt-6-luna", 0.1, 0.5, 0.01, 0.125],
16
18
  ["gpt-5.6-sol", 4, 20, 0.4, 5],
17
19
  ["gpt-5.6-terra", 2, 12, 0.2, 2.5],
18
20
  ["gpt-5.6-luna", 0.2, 1.2, 0.02, 0.25],
@@ -54,6 +56,8 @@ describe("calculateCostUsd", () => {
54
56
  ["claude-opus-4-7", 5, 25, 0.5, 10],
55
57
  ["claude-opus-4-8", 5, 25, 0.5, 10],
56
58
  ["claude-opus-5", 5, 25, 0.5, 10],
59
+ // Opus 5.5 는 cache read 만 0.05x 특례($0.20), cache write 배율은 표준(5m $5 / 1h $8).
60
+ ["claude-opus-5-5", 4, 20, 0.2, 8],
57
61
  ["claude-sonnet-5", 2, 10, 0.2, 4],
58
62
  ])(
59
63
  "%s official Anthropic rates for 5m/1h cache writes",
@@ -92,6 +96,8 @@ describe("calculateCostUsd", () => {
92
96
 
93
97
  it.each([
94
98
  ["gpt-6-astra", 10, 50, 1, 12.5, 5.6125],
99
+ ["gpt-6-sol", 2, 10, 0.2, 2.5, 1.1225],
100
+ ["gpt-6-luna", 0.1, 0.5, 0.01, 0.125, 0.056125],
95
101
  ["gpt-5.6-sol", 4, 20, 0.4, 5, 2.245],
96
102
  ["gpt-5.6-terra", 2, 12, 0.2, 2.5, 1.3225],
97
103
  ["gpt-5.6-luna", 0.2, 1.2, 0.02, 0.25, 0.13225],
@@ -118,6 +124,8 @@ describe("calculateCostUsd", () => {
118
124
 
119
125
  it.each([
120
126
  ["gpt-6-astra", 2.475],
127
+ ["gpt-6-sol", 0.495],
128
+ ["gpt-6-luna", 0.02475],
121
129
  ["gpt-5.6-sol", 0.99],
122
130
  ["gpt-5.6-terra", 0.498],
123
131
  ["gpt-5.6-luna", 0.0498],
@@ -133,6 +141,8 @@ describe("calculateCostUsd", () => {
133
141
 
134
142
  it.each([
135
143
  ["gpt-6-astra", 2.725],
144
+ ["gpt-6-sol", 0.545],
145
+ ["gpt-6-luna", 0.02725],
136
146
  ["gpt-5.6-sol", 1.09],
137
147
  ["gpt-5.6-terra", 0.548],
138
148
  ["gpt-5.6-luna", 0.0548],
@@ -204,6 +214,18 @@ describe("calculateCostUsd", () => {
204
214
  expect(calculateCostUsd("claude-fable-5-1", usage)).toBeCloseTo(5.7625, 10);
205
215
  });
206
216
 
217
+ it("Opus 5.5 는 $4/$20 에 cache read 0.05x($0.20), 1h cache write $8 을 적용한다", () => {
218
+ // uncached 30K*$4 + output 10K*$20 + cache read 50K*$0.20 + 1h write 20K*$8 = 0.12+0.2+0.01+0.16
219
+ expect(
220
+ calculateCostUsd("anthropic/claude-opus-5-5", {
221
+ inputTokens: 100_000,
222
+ outputTokens: 10_000,
223
+ cachedInputTokens: 50_000,
224
+ cacheCreationInputTokens1h: 20_000,
225
+ }),
226
+ ).toBeCloseTo(0.49, 10);
227
+ });
228
+
207
229
  it("Fable 의 5분/1시간 cache write 단가도 구분한다", () => {
208
230
  expect(
209
231
  calculateCostUsd("claude-fable-5", {
@@ -34,7 +34,7 @@ export interface ModelCosts {
34
34
 
35
35
  // ── OpenAI ─────────────────────────────────────────────────────────
36
36
  //
37
- // Standard pricing verified 2026-09-07: https://developers.openai.com/api/docs/pricing
37
+ // Standard pricing verified 2026-09-23 (기존 모델 단가 변동 없음): https://developers.openai.com/api/docs/pricing
38
38
  // 신모델 출시마다 단가가 바뀌므로(5.2→5.4→5.5) 모델 추가 시 반드시 공식 페이지 재확인해야함
39
39
 
40
40
  // GPT-5.4에서 처음 도입된 long-context 할증 (5.2/5.3-codex는 해당 없음, 5.4-mini/nano는 공식 표에서 long-context 단가 없음)
@@ -65,6 +65,26 @@ const OPENAI_COSTS: Record<string, OpenAIModelSpec> = {
65
65
  cacheCreationInputTokens: 12.5,
66
66
  longContext: LONG_CONTEXT_272K,
67
67
  },
68
+ // GPT-6 Sol, Luna (2026-09-22 출시): Standard API pricing verified 2026-09-23. GPT-5.6 Sol/Luna 의 절반 단가.
69
+ // Codex 카탈로그(2026-09-23)는 Sol 에 ultra, Luna 에 max 까지 허용하고 기본 effort 는 둘 다 medium 이다.
70
+ // @see https://developers.openai.com/api/docs/models/gpt-6-sol
71
+ // @see https://developers.openai.com/api/docs/models/gpt-6-luna
72
+ "gpt-6-sol": {
73
+ maxEffort: "ultra",
74
+ inputTokens: 2,
75
+ outputTokens: 10,
76
+ cachedInputTokens: 0.2,
77
+ cacheCreationInputTokens: 2.5,
78
+ longContext: LONG_CONTEXT_272K,
79
+ },
80
+ "gpt-6-luna": {
81
+ maxEffort: "max",
82
+ inputTokens: 0.1,
83
+ outputTokens: 0.5,
84
+ cachedInputTokens: 0.01,
85
+ cacheCreationInputTokens: 0.125,
86
+ longContext: LONG_CONTEXT_272K,
87
+ },
68
88
  // GPT-5.6 Sol, Terra, Luna. cache write 단가는 외부 logger/manual usage 입력을 위해
69
89
  // 유지한다. 현재 OpenAI 응답 usage 에 cache write 토큰 필드가 없으면 비용은 0으로 계산된다.
70
90
  // @see https://developers.openai.com/api/docs/models/gpt-5.6-sol
@@ -140,7 +160,7 @@ function anthropicCosts(
140
160
  };
141
161
  }
142
162
 
143
- // 가격 출처: https://platform.claude.com/docs/en/about-claude/pricing (2026-09-02 확인)
163
+ // 가격 출처: https://platform.claude.com/docs/en/about-claude/pricing (2026-09-23 확인, 기존 모델 단가 변동 없음)
144
164
  const ANTHROPIC_COSTS: Record<string, ModelCosts> = {
145
165
  // Fable 5.1 (2026-09-01 출시): input/output 은 Fable 5 와 같고 cache read 만 0.025x($0.25) 특례.
146
166
  // @see https://platform.claude.com/docs/en/models/fable-5-1/overview
@@ -162,6 +182,9 @@ const ANTHROPIC_COSTS: Record<string, ModelCosts> = {
162
182
  "claude-opus-4-7": anthropicCosts(5, 25),
163
183
  "claude-opus-4-8": anthropicCosts(5, 25),
164
184
  "claude-opus-5": anthropicCosts(5, 25),
185
+ // Opus 5.5 (2026-09-22 출시): $4/$20. cache read 는 0.05x($0.20) 특례, cache write 는 표준 배율($5/$8).
186
+ // @see https://platform.claude.com/docs/en/models/opus-5-5/overview
187
+ "claude-opus-5-5": anthropicCosts(4, 20, { cachedInputTokens: 0.2 }),
165
188
  // Sonnet 5 의 introductory $2/$10 이 정식 단가로 확정됐다. 2026-09-01 에 예정됐던 $3/$15 인상은
166
189
  // 취소됐으므로 날짜 분기 없이 고정 단가로 계산한다.
167
190
  "claude-sonnet-5": anthropicCosts(2, 10),
@@ -7,6 +7,7 @@
7
7
  import {
8
8
  type ImageGenerationMetadata,
9
9
  type ImageGenerationOptions,
10
+ type QgridContent,
10
11
  } from "../../../application/qgrid/qgrid.types";
11
12
  import { type JsonValue, type TokenUsageBreakdown, type UserInput } from "./provider-types";
12
13
 
@@ -28,7 +29,8 @@ export interface ReuseThreadCoord {
28
29
  // provider-무관 이미지 결과. OpenAI 경로에서만 채워지며, 상위(qgrid.dispatcher)가
29
30
  // content 파트로 전파한다. Anthropic 경로는 이 필드를 채우지 않는다.
30
31
  export interface GeneratedImage {
31
- data: string; // base64 PNG
32
+ data: string; // base64 image
33
+ mediaType?: Extract<QgridContent, { type: "image" }>["mediaType"];
32
34
  revisedPrompt: string | null;
33
35
  generation?: ImageGenerationMetadata;
34
36
  }
@@ -8,6 +8,29 @@ import {
8
8
  } from "./openai-backend-protocol";
9
9
 
10
10
  describe("OpenAI Codex backend protocol", () => {
11
+ it("uses returned bytes rather than a mismatched format label", () => {
12
+ expect(normalizeOpenAIEvent({ type: "response.output_item.done", item: {
13
+ type: "image_generation_call", result: "UklGRgAAAABXRUJQ", output_format: "png",
14
+ } })).toMatchObject({ mimeType: "image/webp" });
15
+ });
16
+
17
+ it("detects JPEG when the format label is absent", () => {
18
+ expect(normalizeOpenAIEvent({ type: "response.output_item.done", item: {
19
+ type: "image_generation_call", result: "/9j/",
20
+ } })).toMatchObject({ mimeType: "image/jpeg" });
21
+ });
22
+
23
+ it.each([
24
+ ["png", "iVBORw0KGgo=", "image/png"],
25
+ ["jpeg", "/9j/", "image/jpeg"],
26
+ ["webp", "UklGRgAAAABXRUJQ", "image/webp"],
27
+ ])("preserves %s output MIME type", (format, result, mimeType) => {
28
+ expect(normalizeOpenAIEvent({
29
+ type: "response.output_item.done",
30
+ item: { type: "image_generation_call", result, output_format: format },
31
+ })).toMatchObject({ type: "image", mimeType });
32
+ });
33
+
11
34
  it("preserves raw history and pins Responses controls", () => {
12
35
  const history = [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }];
13
36
  const request = buildOpenAIResponsesRequest({
@@ -175,7 +175,7 @@ export type OpenAINormalizedEvent =
175
175
  type: "image";
176
176
  id?: string;
177
177
  base64: string;
178
- mimeType: "image/png";
178
+ mimeType: "image/png" | "image/jpeg" | "image/webp";
179
179
  revisedPrompt?: string;
180
180
  generation?: ImageGenerationMetadata;
181
181
  }
@@ -209,6 +209,23 @@ function usageFrom(value: unknown): OpenAIUsage | undefined {
209
209
  };
210
210
  }
211
211
 
212
+ export function imageMimeType(
213
+ base64: string,
214
+ outputFormat?: unknown,
215
+ ): "image/png" | "image/jpeg" | "image/webp" {
216
+ // Inspect only the header, without decoding a potentially large image twice.
217
+ const header = Buffer.from(base64.slice(0, 24), "base64");
218
+ if (header.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
219
+ return "image/png";
220
+ if (header[0] === 255 && header[1] === 216 && header[2] === 255) return "image/jpeg";
221
+ if (header.toString("ascii", 0, 4) === "RIFF" && header.toString("ascii", 8, 12) === "WEBP")
222
+ return "image/webp";
223
+ if (outputFormat === "jpeg") return "image/jpeg";
224
+ if (outputFormat === "webp") return "image/webp";
225
+ if (outputFormat === undefined || outputFormat === "png") return "image/png";
226
+ throw new OpenAIProtocolError(`Unsupported image output format: ${String(outputFormat)}`);
227
+ }
228
+
212
229
  export function normalizeOpenAIEvent(raw: unknown): OpenAINormalizedEvent | undefined {
213
230
  if (!raw || typeof raw !== "object") return undefined;
214
231
  const event = raw as Record<string, unknown>;
@@ -231,7 +248,7 @@ export function normalizeOpenAIEvent(raw: unknown): OpenAINormalizedEvent | unde
231
248
  type: "image",
232
249
  ...(typeof item.id === "string" ? { id: item.id } : {}),
233
250
  base64: item.result,
234
- mimeType: "image/png",
251
+ mimeType: imageMimeType(item.result, item.output_format),
235
252
  ...(typeof item.revised_prompt === "string"
236
253
  ? { revisedPrompt: item.revised_prompt }
237
254
  : typeof item.revisedPrompt === "string"
@@ -51,6 +51,25 @@ async function tickTimer(): Promise<void> {
51
51
  }
52
52
 
53
53
  describe("OpenAIDispatcher direct runtime", () => {
54
+ it.each(["png", "jpeg", "webp"] as const)("forwards %s format and preserves returned MIME", async (outputFormat) => {
55
+ const args = QueryInput.parse({ prompt: "draw", imageGeneration: true, imageGenerationOptions: { outputFormat } });
56
+ let body: ReturnType<typeof buildOpenAIResponsesRequest> | undefined;
57
+ const d = dispatcher((options) => {
58
+ body = buildOpenAIResponsesRequest(options);
59
+ return events({ type: "image", base64: "data", mimeType: `image/${outputFormat}` }, { type: "completed", responseId: "r" });
60
+ });
61
+ await d.onTokenAdded(1, "one", credentials);
62
+ const result = await d.generate(request({ imageGeneration: args.imageGeneration, imageGenerationOptions: args.imageGenerationOptions }));
63
+ expect(body?.tools).toEqual([{ type: "image_generation", output_format: outputFormat }]);
64
+ expect(result.images).toMatchObject([{ mediaType: `image/${outputFormat}` }]);
65
+ });
66
+
67
+ it("rejects unsupported output formats and non-PNG transparent requests", () => {
68
+ for (const imageGenerationOptions of [{ outputFormat: "gif" }, { outputFormat: "webp", background: "transparent" }, { outputFormat: "jpeg", background: "transparent" }]) {
69
+ expect(QueryInput.safeParse({ prompt: "draw", imageGeneration: true, imageGenerationOptions }).success).toBe(false);
70
+ }
71
+ });
72
+
54
73
  beforeEach(() => { refreshMock.mockReset(); });
55
74
 
56
75
  it("refreshes a quota 401 once, shares the recovery, and uses the new credentials", async () => {
@@ -565,6 +584,9 @@ describe("effort 어휘 해석 (Codex 카탈로그 기준)", () => {
565
584
 
566
585
  it("모델이 지원하는 값은 그대로 보내고, 상한 초과·공개 API 어휘는 reasoning 에서 빼 백엔드 기본값을 쓴다", async () => {
567
586
  await expect(mappedEffortFor("gpt-6-astra", "ultra")).resolves.toBe("ultra");
587
+ await expect(mappedEffortFor("gpt-6-sol", "ultra")).resolves.toBe("ultra");
588
+ await expect(mappedEffortFor("gpt-6-luna", "max")).resolves.toBe("max");
589
+ await expect(mappedEffortFor("gpt-6-luna", "ultra")).resolves.toBeUndefined();
568
590
  await expect(mappedEffortFor("gpt-5.6-terra", "ultra")).resolves.toBe("ultra");
569
591
  await expect(mappedEffortFor("gpt-5.5", "xhigh")).resolves.toBe("xhigh");
570
592
  await expect(mappedEffortFor("gpt-5.5", "max")).resolves.toBeUndefined();
@@ -112,6 +112,7 @@ function asReasoning(req: GenerateRequest): OpenAIResponsesOptions["reasoning"]
112
112
  }
113
113
 
114
114
  function requestOptions(req: GenerateRequest): OpenAIResponsesOptions {
115
+ const { outputFormat, ...imageControls } = req.imageGenerationOptions ?? {};
115
116
  return {
116
117
  model: req.model ?? "",
117
118
  ...(req.systemPrompt ? { instructions: req.systemPrompt } : {}),
@@ -124,7 +125,14 @@ function requestOptions(req: GenerateRequest): OpenAIResponsesOptions {
124
125
  ...(req.promptCacheKey ? { promptCacheKey: req.promptCacheKey } : {}),
125
126
  ...(req.outputSchema ? { outputSchema: { schema: req.outputSchema as JsonValue } } : {}),
126
127
  ...(req.imageGeneration
127
- ? { imageGeneration: req.imageGenerationOptions ? { ...req.imageGenerationOptions } : true }
128
+ ? {
129
+ imageGeneration: req.imageGenerationOptions
130
+ ? {
131
+ ...imageControls,
132
+ ...(outputFormat ? { output_format: outputFormat } : {}),
133
+ }
134
+ : true,
135
+ }
128
136
  : {}),
129
137
  };
130
138
  }
@@ -320,6 +328,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
320
328
  imageAttempted = true;
321
329
  images.push({
322
330
  data: event.base64,
331
+ mediaType: event.mimeType,
323
332
  revisedPrompt: event.revisedPrompt ?? null,
324
333
  ...(event.generation ? { generation: event.generation } : {}),
325
334
  });
@@ -31,6 +31,23 @@ async function run(fetchImpl: typeof fetch, overrides = {}) {
31
31
  }
32
32
 
33
33
  describe("standalone transparent images", () => {
34
+ it("rejects non-PNG formats before a network request", async () => {
35
+ const fetchMock = vi.fn<typeof fetch>();
36
+ await expect(run(fetchMock, { imageGeneration: { background: "transparent", output_format: "webp" } })).rejects.toThrow("only supports PNG");
37
+ expect(fetchMock).not.toHaveBeenCalled();
38
+ });
39
+
40
+ it("preserves a previous JPEG image MIME when switching to transparent editing", async () => {
41
+ const fetchMock = vi.fn<typeof fetch>(async () => Response.json(result()));
42
+ await run(fetchMock, { history: [
43
+ { type: "image_generation_call", result: "/9j/", output_format: "jpeg" },
44
+ { role: "user", content: "Remove the background" },
45
+ ] });
46
+ const [url, init] = fetchMock.mock.calls[0]!;
47
+ expect(url).toContain("/images/edits");
48
+ expect(JSON.parse(String(init?.body)).images).toEqual([{ image_url: "data:image/jpeg;base64,/9j/" }]);
49
+ });
50
+
34
51
  it("uses the Images endpoint and preserves actual metadata without charging a driver", async () => {
35
52
  const fetchMock = vi.fn<typeof fetch>(async () => Response.json(result()));
36
53
  const events = await run(fetchMock);
@@ -4,6 +4,7 @@ import { z } from "zod";
4
4
  import { ImageGenerationMetadata } from "../../../application/qgrid/qgrid.types";
5
5
  import {
6
6
  CHATGPT_CODEX_RESPONSES_URL,
7
+ imageMimeType,
7
8
  OpenAIProtocolError,
8
9
  type OpenAINormalizedEvent,
9
10
  type OpenAIResponsesOptions,
@@ -26,13 +27,18 @@ export function buildStandaloneImageRequest(options: OpenAIResponsesOptions) {
26
27
  "Standalone images require an explicit transparent background request",
27
28
  );
28
29
  }
30
+ if (controls.output_format && controls.output_format !== "png") {
31
+ throw new OpenAIProtocolError("Transparent image generation only supports PNG output");
32
+ }
29
33
  const text: string[] = [];
30
34
  const images: Array<{ image_url: string }> = [];
31
35
  if (options.instructions) text.push(`System instructions:\n${options.instructions}`);
32
36
  for (const item of options.history) {
33
37
  if (item.type === "reasoning") continue;
34
38
  if (item.type === "image_generation_call" && typeof item.result === "string") {
35
- images.push({ image_url: `data:image/png;base64,${item.result}` });
39
+ images.push({
40
+ image_url: `data:${imageMimeType(item.result, item.output_format)};base64,${item.result}`,
41
+ });
36
42
  continue;
37
43
  }
38
44
  if (item.type !== "message" && typeof item.role !== "string") {