@vellumai/assistant 0.12.2-dev.202609180126.8de5565 → 0.12.2-dev.202609181315.9aaf787

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 (31) hide show
  1. package/openapi.yaml +1 -1
  2. package/package.json +1 -1
  3. package/src/__tests__/config-model-image-provider.test.ts +39 -0
  4. package/src/__tests__/config-schema.test.ts +16 -0
  5. package/src/__tests__/image-credentials.test.ts +49 -0
  6. package/src/__tests__/image-service-dispatcher.test.ts +45 -0
  7. package/src/__tests__/media-generate-image.test.ts +49 -1
  8. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +7 -11
  9. package/src/config/schemas/services.ts +9 -3
  10. package/src/daemon/conversation-agent-loop.ts +3 -5
  11. package/src/daemon/handlers/config-model.ts +7 -5
  12. package/src/media/__tests__/image-models.test.ts +58 -0
  13. package/src/media/image-credentials.ts +5 -0
  14. package/src/media/image-models.ts +52 -0
  15. package/src/media/image-service.ts +8 -0
  16. package/src/media/openrouter-image-service.test.ts +133 -0
  17. package/src/media/openrouter-image-service.ts +153 -0
  18. package/src/media/types.ts +1 -1
  19. package/src/messaging/providers/discord/send.ts +1 -15
  20. package/src/messaging/providers/discord/transport.ts +2 -2
  21. package/src/messaging/providers/telegram-bot/transport.ts +3 -10
  22. package/src/oauth/oauth-store.ts +1 -3
  23. package/src/persistence/llm-usage-store.ts +3 -9
  24. package/src/plugins/defaults/memory/context-search/agent-protocol.ts +2 -9
  25. package/src/providers/openai/__tests__/google-thought-signature.test.ts +1 -2
  26. package/src/providers/openai/google-thought-signature.ts +0 -6
  27. package/src/runtime/routes/__tests__/image-generation-routes.test.ts +97 -0
  28. package/src/runtime/routes/image-generation-routes.ts +8 -14
  29. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +9 -9
  30. package/src/runtime/routes/inbound-stages/background-dispatch.ts +1 -5
  31. package/src/runtime/routes/llm-context-normalization.ts +1 -7
package/openapi.yaml CHANGED
@@ -16217,7 +16217,7 @@ paths:
16217
16217
  post:
16218
16218
  operationId: imagegeneration_generate_post
16219
16219
  summary: Generate or edit images using AI
16220
- description: Calls the configured image-generation provider (Gemini or OpenAI) to produce one or more images.
16220
+ description: Calls the configured image-generation provider (Gemini, OpenAI, or OpenRouter) to produce one or more images.
16221
16221
  tags:
16222
16222
  - image-generation
16223
16223
  requestBody:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.2-dev.202609180126.8de5565",
3
+ "version": "0.12.2-dev.202609181315.9aaf787",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -110,6 +110,45 @@ describe("setImageGenModel — provider derived from model prefix", () => {
110
110
  });
111
111
  });
112
112
 
113
+ describe("setImageGenModel with provider openrouter", () => {
114
+ test("a model change leaves provider openrouter untouched", () => {
115
+ writeConfig({
116
+ services: {
117
+ "image-generation": {
118
+ provider: "openrouter",
119
+ model: "google/gemini-3.1-flash-image-preview",
120
+ },
121
+ },
122
+ });
123
+
124
+ setImageGenModel("openai/gpt-image-2", makeCtx());
125
+
126
+ const raw = readConfig() as {
127
+ services: { "image-generation": { provider: string; model: string } };
128
+ };
129
+ expect(raw.services["image-generation"].model).toBe("openai/gpt-image-2");
130
+ expect(raw.services["image-generation"].provider).toBe("openrouter");
131
+ });
132
+
133
+ test("a google/ slug does not rewrite provider to gemini", () => {
134
+ writeConfig({
135
+ services: {
136
+ "image-generation": {
137
+ provider: "openrouter",
138
+ model: "openai/gpt-image-2",
139
+ },
140
+ },
141
+ });
142
+
143
+ setImageGenModel("google/gemini-3.1-flash-image-preview", makeCtx());
144
+
145
+ const raw = readConfig() as {
146
+ services: { "image-generation": { provider: string; model: string } };
147
+ };
148
+ expect(raw.services["image-generation"].provider).toBe("openrouter");
149
+ });
150
+ });
151
+
113
152
  describe("setImageGenModel with provider vellum", () => {
114
153
  test("a model change leaves provider vellum untouched", () => {
115
154
  writeConfig({
@@ -110,6 +110,22 @@ describe("AssistantConfigSchema", () => {
110
110
  expect(result.services["image-generation"].model).toBe("gpt-image-2");
111
111
  });
112
112
 
113
+ test("accepts openrouter as an image generation provider", () => {
114
+ const result = AssistantConfigSchema.parse({
115
+ services: {
116
+ "image-generation": {
117
+ provider: "openrouter",
118
+ model: "google/gemini-3.1-flash-image-preview",
119
+ },
120
+ },
121
+ });
122
+
123
+ expect(result.services["image-generation"].provider).toBe("openrouter");
124
+ expect(result.services["image-generation"].model).toBe(
125
+ "google/gemini-3.1-flash-image-preview",
126
+ );
127
+ });
128
+
113
129
  test("accepts Tavily as a web search provider", () => {
114
130
  const result = AssistantConfigSchema.parse({
115
131
  services: {
@@ -136,6 +136,34 @@ describe("resolveImageGenCredentials", () => {
136
136
  expect(result.errorHint).toBeDefined();
137
137
  expect(result.errorHint).toContain("OpenAI API key");
138
138
  });
139
+
140
+ test("returns direct credentials for openrouter when key is present", async () => {
141
+ mockProviderKey = "openrouter-api-key";
142
+
143
+ const result = await resolveImageGenCredentials({
144
+ provider: "openrouter",
145
+ managed: false,
146
+ });
147
+
148
+ expect(result.errorHint).toBeUndefined();
149
+ expect(result.credentials).toEqual({
150
+ type: "direct",
151
+ apiKey: "openrouter-api-key",
152
+ });
153
+ });
154
+
155
+ test("returns errorHint mentioning 'OpenRouter API key' when no key is set", async () => {
156
+ mockProviderKey = undefined;
157
+
158
+ const result = await resolveImageGenCredentials({
159
+ provider: "openrouter",
160
+ managed: false,
161
+ });
162
+
163
+ expect(result.credentials).toBeUndefined();
164
+ expect(result.errorHint).toBeDefined();
165
+ expect(result.errorHint).toContain("OpenRouter API key");
166
+ });
139
167
  });
140
168
  });
141
169
 
@@ -173,6 +201,27 @@ describe("resolveImageGenRouting", () => {
173
201
  ).toEqual({ backendProvider: "openai", managed: false });
174
202
  });
175
203
 
204
+ test("OpenRouter keeps ownership for google/ and openai/ slugs", () => {
205
+ expect(
206
+ resolveImageGenRouting({
207
+ provider: "openrouter",
208
+ model: "google/gemini-3.1-flash-image-preview",
209
+ }),
210
+ ).toEqual({ backendProvider: "openrouter", managed: false });
211
+ expect(
212
+ resolveImageGenRouting(
213
+ { provider: "openrouter", model: "google/gemini-3.1-flash-image-preview" },
214
+ "openai/gpt-image-2",
215
+ ),
216
+ ).toEqual({ backendProvider: "openrouter", managed: false });
217
+ expect(
218
+ resolveImageGenRouting(
219
+ { provider: "openrouter", model: "google/gemini-3.1-flash-image-preview" },
220
+ "gpt-image-2",
221
+ ),
222
+ ).toEqual({ backendProvider: "openrouter", managed: false });
223
+ });
224
+
176
225
  test("vellum with an unavailable platform is a hard error, not a BYOK fallback", async () => {
177
226
  // Billing rule: an explicit vellum choice never silently spends a stored
178
227
  // provider key.
@@ -17,8 +17,10 @@ interface GenerateCall {
17
17
 
18
18
  let geminiCalls: GenerateCall[] = [];
19
19
  let openaiCalls: GenerateCall[] = [];
20
+ let openrouterCalls: GenerateCall[] = [];
20
21
  let geminiErrorCalls: unknown[] = [];
21
22
  let openaiErrorCalls: unknown[] = [];
23
+ let openrouterErrorCalls: unknown[] = [];
22
24
 
23
25
  let geminiResult: ImageGenerationResult = {
24
26
  images: [],
@@ -28,6 +30,10 @@ let openaiResult: ImageGenerationResult = {
28
30
  images: [],
29
31
  resolvedModel: "openai-mock",
30
32
  };
33
+ let openrouterResult: ImageGenerationResult = {
34
+ images: [],
35
+ resolvedModel: "openrouter-mock",
36
+ };
31
37
 
32
38
  // ---------------------------------------------------------------------------
33
39
  // Mock underlying services — must run before the module under test is imported
@@ -61,6 +67,20 @@ mock.module("../media/openai-image-service.js", () => ({
61
67
  },
62
68
  }));
63
69
 
70
+ mock.module("../media/openrouter-image-service.js", () => ({
71
+ generateImageOpenRouter: async (
72
+ credentials: ImageGenCredentials,
73
+ request: ImageGenerationRequest,
74
+ ): Promise<ImageGenerationResult> => {
75
+ openrouterCalls.push({ credentials, request });
76
+ return openrouterResult;
77
+ },
78
+ mapOpenRouterError: (error: unknown): string => {
79
+ openrouterErrorCalls.push(error);
80
+ return "openrouter-mapped";
81
+ },
82
+ }));
83
+
64
84
  // Import after mocking
65
85
  import {
66
86
  generateImage,
@@ -90,8 +110,10 @@ describe("image-service dispatcher", () => {
90
110
  beforeEach(() => {
91
111
  geminiCalls = [];
92
112
  openaiCalls = [];
113
+ openrouterCalls = [];
93
114
  geminiErrorCalls = [];
94
115
  openaiErrorCalls = [];
116
+ openrouterErrorCalls = [];
95
117
  geminiResult = {
96
118
  images: [{ mimeType: "image/png", dataBase64: "gemini-bytes" }],
97
119
  resolvedModel: "gemini-3.1-flash-image-preview",
@@ -100,6 +122,10 @@ describe("image-service dispatcher", () => {
100
122
  images: [{ mimeType: "image/png", dataBase64: "openai-bytes" }],
101
123
  resolvedModel: "gpt-image-2",
102
124
  };
125
+ openrouterResult = {
126
+ images: [{ mimeType: "image/png", dataBase64: "openrouter-bytes" }],
127
+ resolvedModel: "google/gemini-3.1-flash-image-preview",
128
+ };
103
129
  });
104
130
 
105
131
  test("generateImage('gemini', ...) delegates to the Gemini implementation", async () => {
@@ -131,6 +157,17 @@ describe("image-service dispatcher", () => {
131
157
  expect(mapped).toBe("gemini-mapped");
132
158
  });
133
159
 
160
+ test("generateImage('openrouter', ...) delegates to the OpenRouter implementation", async () => {
161
+ const result = await generateImage("openrouter", directCreds, request);
162
+
163
+ expect(openrouterCalls).toHaveLength(1);
164
+ expect(openrouterCalls[0]?.credentials).toBe(directCreds);
165
+ expect(openrouterCalls[0]?.request).toBe(request);
166
+ expect(geminiCalls).toHaveLength(0);
167
+ expect(openaiCalls).toHaveLength(0);
168
+ expect(result).toEqual(openrouterResult);
169
+ });
170
+
134
171
  test("mapImageGenError('openai', err) delegates to mapOpenAIError", () => {
135
172
  const err = new Error("kapow");
136
173
  const mapped = mapImageGenError("openai", err);
@@ -139,6 +176,14 @@ describe("image-service dispatcher", () => {
139
176
  expect(geminiErrorCalls).toEqual([]);
140
177
  expect(mapped).toBe("openai-mapped");
141
178
  });
179
+
180
+ test("mapImageGenError('openrouter', err) delegates to mapOpenRouterError", () => {
181
+ const err = new Error("or-boom");
182
+ const mapped = mapImageGenError("openrouter", err);
183
+
184
+ expect(openrouterErrorCalls).toEqual([err]);
185
+ expect(mapped).toBe("openrouter-mapped");
186
+ });
142
187
  });
143
188
 
144
189
  describe("providerForModel", () => {
@@ -21,6 +21,7 @@ import { setConfig } from "./helpers/set-config.js";
21
21
 
22
22
  let mockGeminiKey: string | undefined = "test-gemini-key";
23
23
  let mockOpenAIKey: string | undefined = "test-openai-key";
24
+ let mockOpenRouterKey: string | undefined = "test-openrouter-key";
24
25
  let mockGenerateResult = {
25
26
  images: [{ mimeType: "image/png", dataBase64: "generated-data" }],
26
27
  text: "A beautiful image",
@@ -40,7 +41,7 @@ let mockGenerateDelayMs = 0;
40
41
  */
41
42
  function seedImageGenService(
42
43
  overrides: {
43
- provider?: "vellum" | "gemini" | "openai";
44
+ provider?: "vellum" | "gemini" | "openai" | "openrouter";
44
45
  model?: string;
45
46
  } = {},
46
47
  ): void {
@@ -64,6 +65,9 @@ mock.module("../security/secure-keys.js", () => ({
64
65
  if (provider === "openai") {
65
66
  return mockOpenAIKey;
66
67
  }
68
+ if (provider === "openrouter") {
69
+ return mockOpenRouterKey;
70
+ }
67
71
  return undefined;
68
72
  },
69
73
  }));
@@ -143,6 +147,7 @@ const CONFIG_DIR = join(
143
147
  beforeEach(() => {
144
148
  mockGeminiKey = "test-gemini-key";
145
149
  mockOpenAIKey = "test-openai-key";
150
+ mockOpenRouterKey = "test-openrouter-key";
146
151
  seedImageGenService();
147
152
  mockGenerateResult = {
148
153
  images: [{ mimeType: "image/png", dataBase64: "generated-data" }],
@@ -308,6 +313,49 @@ describe("image-studio skill script wrapper", () => {
308
313
  expect(result.content).not.toContain("No Gemini API key");
309
314
  });
310
315
 
316
+ test("OpenRouter accepts an arbitrary slug and keeps OpenRouter ownership", async () => {
317
+ seedImageGenService({
318
+ provider: "openrouter",
319
+ model: "google/gemini-3.1-flash-image-preview",
320
+ });
321
+
322
+ const result = await run(
323
+ { prompt: "a nebula", model: "black-forest-labs/flux" },
324
+ fakeContext,
325
+ );
326
+
327
+ expect(result.isError).toBe(false);
328
+ expect(lastGenerateProvider).toBe("openrouter");
329
+ expect(lastGenerateCredentials).toEqual({
330
+ type: "direct",
331
+ apiKey: "test-openrouter-key",
332
+ });
333
+ expect(lastGenerateRequest?.model).toBe("black-forest-labs/flux");
334
+ });
335
+
336
+ test("OpenRouter qualifies a built-in alias and keeps OpenRouter ownership", async () => {
337
+ seedImageGenService({ provider: "openrouter" });
338
+
339
+ const result = await run({ prompt: "a robot", model: "openai" }, fakeContext);
340
+
341
+ expect(result.isError).toBe(false);
342
+ expect(lastGenerateProvider).toBe("openrouter");
343
+ expect(lastGenerateRequest?.model).toBe("openai/gpt-image-2");
344
+ });
345
+
346
+ test("OpenRouter returns a missing-key hint without falling back to Gemini", async () => {
347
+ seedImageGenService({ provider: "openrouter" });
348
+ mockOpenRouterKey = undefined;
349
+ mockGeminiKey = "gemini-key-should-not-be-used";
350
+
351
+ const result = await run({ prompt: "a cat" }, fakeContext);
352
+
353
+ expect(result.isError).toBe(true);
354
+ expect(result.content).toContain("OpenRouter API key");
355
+ expect(result.content).not.toContain("No Gemini API key");
356
+ expect(lastGenerateProvider).toBeNull();
357
+ });
358
+
311
359
  test("explicit model override routes to owning provider (gemini config → openai call)", async () => {
312
360
  // Config says the user's default provider is gemini, but the LLM
313
361
  // explicitly requests a gpt-* model. The tool must dispatch to OpenAI
@@ -5,10 +5,7 @@ import {
5
5
  resolveImageGenCredentials,
6
6
  resolveImageGenRouting,
7
7
  } from "../../../../media/image-credentials.js";
8
- import {
9
- describeImageModels,
10
- resolveImageModel,
11
- } from "../../../../media/image-models.js";
8
+ import { resolveRequestedImageModel } from "../../../../media/image-models.js";
12
9
  import {
13
10
  generateImage,
14
11
  mapImageGenError,
@@ -115,18 +112,17 @@ export async function run(
115
112
  const config = getConfig();
116
113
  const svc = config.services["image-generation"];
117
114
  let modelOverride = input.model;
118
- // Resolve tier aliases (fast, quality, openai) to concrete model IDs via
119
- // the registry. Unknown values get an error listing the current catalog so
120
- // callers can self-correct without a stale schema enum.
115
+ // Resolve aliases and OpenRouter slugs against the configured provider.
116
+ // Built-in providers still list the current catalog on unknown values.
121
117
  if (typeof modelOverride === "string" && modelOverride) {
122
- const entry = resolveImageModel(modelOverride);
123
- if (!entry) {
118
+ const resolved = resolveRequestedImageModel(modelOverride, svc.provider);
119
+ if (resolved.error) {
124
120
  return {
125
- content: `Unknown model "${modelOverride}". Available models and aliases:\n${describeImageModels()}\n\nRetry with one of the aliases above, or omit the model parameter to use the configured default.`,
121
+ content: `${resolved.error}\n\nRetry with one of the aliases above, or omit the model parameter to use the configured default.`,
126
122
  isError: true,
127
123
  };
128
124
  }
129
- modelOverride = entry.id;
125
+ modelOverride = resolved.model;
130
126
  }
131
127
  // Backend and managed-ness resolve together: an explicit model re-routes
132
128
  // to the model's backend (e.g. `gpt-image-2` under a gemini config routes
@@ -22,7 +22,12 @@ export const VALID_INFERENCE_PROVIDERS = [
22
22
 
23
23
  // `vellum` generates through the platform runtime proxy; the backend
24
24
  // (gemini/openai) derives from the selected model's prefix at request time.
25
- const VALID_IMAGE_GEN_PROVIDERS = ["vellum", "gemini", "openai"] as const;
25
+ const VALID_IMAGE_GEN_PROVIDERS = [
26
+ "vellum",
27
+ "gemini",
28
+ "openai",
29
+ "openrouter",
30
+ ] as const;
26
31
 
27
32
  /**
28
33
  * Derived from `SEARCH_PROVIDER_CATALOG`. Adding a new web-search provider
@@ -56,8 +61,9 @@ const InferenceServiceSchema = z.object({});
56
61
  /**
57
62
  * Image generation carries no `mode`: `provider` is the only axis. `"vellum"`
58
63
  * generates through the platform runtime proxy for the model-appropriate
59
- * backend, billed to Vellum credits; `"gemini"`/`"openai"` use the user's
60
- * key. A `mode` key sent by an older client is stripped at parse.
64
+ * backend, billed to Vellum credits; `"gemini"`/`"openai"`/`"openrouter"`
65
+ * use the user's key. A `mode` key sent by an older client is stripped at
66
+ * parse.
61
67
  */
62
68
  const ImageGenerationServiceSchema = z.object({
63
69
  provider: z.enum(VALID_IMAGE_GEN_PROVIDERS).default("gemini"),
@@ -677,8 +677,6 @@ export async function runAgentLoopImpl(
677
677
  ctx.currentTurnOverrideProfile = currentOverrideProfile;
678
678
  return currentOverrideProfile;
679
679
  };
680
- const resolveCurrentOverrideProfile = (): string | undefined =>
681
- refreshCurrentProfileState();
682
680
  const resolveCurrentMaxInputTokens = (): number => {
683
681
  refreshCurrentProfileState();
684
682
  return currentEffectiveContextWindow.maxInputTokens;
@@ -704,7 +702,7 @@ export async function runAgentLoopImpl(
704
702
  };
705
703
 
706
704
  // Initial value for `createToolExecutor` to read into
707
- // `ToolContext.overrideProfile`. `resolveCurrentOverrideProfile` refreshes
705
+ // `ToolContext.overrideProfile`. `refreshCurrentProfileState` refreshes
708
706
  // this between model calls so a confirmed profile session opened by a tool
709
707
  // applies to later tool executions and nested subagents in the same turn.
710
708
  ctx.currentTurnOverrideProfile = turnOverrideProfile;
@@ -1486,7 +1484,7 @@ export async function runAgentLoopImpl(
1486
1484
  trust: loopTrust,
1487
1485
  overrideProfile: turnOverrideProfile,
1488
1486
  ...(forceOverrideProfile ? { forceOverrideProfile: true } : {}),
1489
- resolveOverrideProfile: resolveCurrentOverrideProfile,
1487
+ resolveOverrideProfile: refreshCurrentProfileState,
1490
1488
  resolveContextWindow,
1491
1489
  compactInPlace,
1492
1490
  isNonInteractive,
@@ -1901,7 +1899,7 @@ export async function runAgentLoopImpl(
1901
1899
  callSite: turnCallSite,
1902
1900
  overrideProfile:
1903
1901
  state.exchangeInferenceProfile ??
1904
- resolveCurrentOverrideProfile() ??
1902
+ refreshCurrentProfileState() ??
1905
1903
  null,
1906
1904
  ...(state.exchangeInferenceProfile !== undefined
1907
1905
  ? { forceOverrideProfile: true }
@@ -98,14 +98,16 @@ export function setImageGenModel(modelId: string, ctx: ModelSetContext): void {
98
98
  // Keep the derived provider in sync with the selected model so downstream
99
99
  // routing never sends a Gemini request to an OpenAI model (or vice versa).
100
100
  // The prefix logic is shared with workspace migration 006-services-config
101
- // via providerForImageModelPrefix(). Provider "vellum" is exempt: it is
102
- // managed for every model (the backend derives from the model at request
103
- // time), and rewriting it here would silently move a managed user onto a
104
- // BYOK provider they may hold no key for.
101
+ // via providerForImageModelPrefix(). Providers "vellum" and "openrouter"
102
+ // are exempt: vellum is managed for every model, and openrouter owns every
103
+ // selected slug (including google/ and openai/ prefixes). Rewriting either
104
+ // would silently move the user onto a BYOK provider they may hold no key
105
+ // for.
105
106
  const services = raw.services as
106
107
  | Record<string, { provider?: string }>
107
108
  | undefined;
108
- if (services?.["image-generation"]?.provider !== "vellum") {
109
+ const currentProvider = services?.["image-generation"]?.provider;
110
+ if (currentProvider !== "vellum" && currentProvider !== "openrouter") {
109
111
  setServiceField(
110
112
  raw,
111
113
  "image-generation",
@@ -2,9 +2,12 @@ import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import {
4
4
  DEFAULT_IMAGE_MODEL,
5
+ DEFAULT_OPENROUTER_IMAGE_MODEL,
5
6
  describeImageModels,
6
7
  IMAGE_MODELS,
8
+ qualifyImageModelForOpenRouter,
7
9
  resolveImageModel,
10
+ resolveRequestedImageModel,
8
11
  } from "../image-models.js";
9
12
  import { providerForModel } from "../image-service.js";
10
13
 
@@ -55,3 +58,58 @@ describe("image model registry", () => {
55
58
  }
56
59
  });
57
60
  });
61
+
62
+ describe("resolveRequestedImageModel", () => {
63
+ test("qualifies built-in aliases for OpenRouter", () => {
64
+ expect(resolveRequestedImageModel("fast", "openrouter")).toEqual({
65
+ model: "google/gemini-3.1-flash-image-preview",
66
+ });
67
+ expect(resolveRequestedImageModel("openai", "openrouter")).toEqual({
68
+ model: "openai/gpt-image-2",
69
+ });
70
+ expect(qualifyImageModelForOpenRouter("quality")).toBe(
71
+ "google/gemini-3-pro-image-preview",
72
+ );
73
+ });
74
+
75
+ test("qualifies bare built-in IDs for OpenRouter", () => {
76
+ expect(
77
+ resolveRequestedImageModel("gemini-3.1-flash-image-preview", "openrouter"),
78
+ ).toEqual({ model: DEFAULT_OPENROUTER_IMAGE_MODEL });
79
+ expect(resolveRequestedImageModel("gpt-image-2", "openrouter")).toEqual({
80
+ model: "openai/gpt-image-2",
81
+ });
82
+ });
83
+
84
+ test("accepts arbitrary trimmed OpenRouter slugs", () => {
85
+ expect(
86
+ resolveRequestedImageModel(" google/gemini-3.5-flash ", "openrouter"),
87
+ ).toEqual({ model: "google/gemini-3.5-flash" });
88
+ expect(
89
+ resolveRequestedImageModel("openai/gpt-image-2", "openrouter"),
90
+ ).toEqual({ model: "openai/gpt-image-2" });
91
+ });
92
+
93
+ test("rejects unknown models for built-in providers", () => {
94
+ const gemini = resolveRequestedImageModel(
95
+ "google/gemini-3.5-flash",
96
+ "gemini",
97
+ );
98
+ expect(gemini.model).toBeUndefined();
99
+ expect(gemini.error).toContain("Unknown model");
100
+
101
+ const openai = resolveRequestedImageModel("black-forest-labs/flux", "openai");
102
+ expect(openai.model).toBeUndefined();
103
+ expect(openai.error).toContain("Unknown model");
104
+
105
+ expect(resolveRequestedImageModel("fast", "vellum")).toEqual({
106
+ model: "gemini-3.1-flash-image-preview",
107
+ });
108
+ });
109
+
110
+ test("ignores empty or non-string input", () => {
111
+ expect(resolveRequestedImageModel("", "openrouter")).toEqual({});
112
+ expect(resolveRequestedImageModel(" ", "openrouter")).toEqual({});
113
+ expect(resolveRequestedImageModel(undefined, "gemini")).toEqual({});
114
+ });
115
+ });
@@ -37,6 +37,9 @@ export function resolveImageGenRouting(
37
37
  : svc.model;
38
38
  return { backendProvider: providerForImageModelPrefix(model), managed };
39
39
  }
40
+ if (svc.provider === "openrouter") {
41
+ return { backendProvider: "openrouter", managed: false };
42
+ }
40
43
  return {
41
44
  backendProvider: providerForModel(
42
45
  modelOverride,
@@ -101,5 +104,7 @@ function providerKeyHint(provider: ImageGenProvider): string {
101
104
  return "No Gemini API key configured. Please set your Gemini API key in Settings → Models & Services.";
102
105
  case "openai":
103
106
  return "No OpenAI API key configured. Please set your OpenAI API key in Settings → Models & Services.";
107
+ case "openrouter":
108
+ return "No OpenRouter API key configured. Please set your OpenRouter API key in Settings → Models & Services.";
104
109
  }
105
110
  }
@@ -55,6 +55,58 @@ export function resolveImageModel(model: string): ImageModelEntry | undefined {
55
55
  return IMAGE_MODELS.find((m) => m.alias === model || m.id === model);
56
56
  }
57
57
 
58
+ const OPENROUTER_VENDOR_PREFIX: Record<ImageModelProvider, string> = {
59
+ gemini: "google",
60
+ openai: "openai",
61
+ };
62
+
63
+ export const DEFAULT_OPENROUTER_IMAGE_MODEL =
64
+ "google/gemini-3.1-flash-image-preview";
65
+
66
+ /**
67
+ * Qualify a built-in alias or concrete ID for OpenRouter's Images API.
68
+ * Unknown values are returned trimmed so live catalog slugs pass through.
69
+ */
70
+ export function qualifyImageModelForOpenRouter(model: string): string {
71
+ const trimmed = model.trim();
72
+ const entry = resolveImageModel(trimmed);
73
+ if (!entry) {
74
+ return trimmed;
75
+ }
76
+ return `${OPENROUTER_VENDOR_PREFIX[entry.provider]}/${entry.id}`;
77
+ }
78
+
79
+ /**
80
+ * Resolve a caller-supplied image model against the configured provider.
81
+ *
82
+ * OpenRouter accepts any trimmed slug from its live catalog. Built-in
83
+ * aliases and bare IDs are qualified (`fast` ->
84
+ * `google/gemini-3.1-flash-image-preview`). Gemini, OpenAI, and Vellum
85
+ * still require a registry match.
86
+ */
87
+ export function resolveRequestedImageModel(
88
+ model: unknown,
89
+ configuredProvider: string,
90
+ ): { model?: string; error?: string } {
91
+ if (typeof model !== "string" || !model) {
92
+ return {};
93
+ }
94
+ const trimmed = model.trim();
95
+ if (!trimmed) {
96
+ return {};
97
+ }
98
+ if (configuredProvider === "openrouter") {
99
+ return { model: qualifyImageModelForOpenRouter(trimmed) };
100
+ }
101
+ const entry = resolveImageModel(trimmed);
102
+ if (!entry) {
103
+ return {
104
+ error: `Unknown model "${trimmed}". Available models and aliases:\n${describeImageModels()}`,
105
+ };
106
+ }
107
+ return { model: entry.id };
108
+ }
109
+
58
110
  /**
59
111
  * One line per model: "alias -> id (label)". Used in CLI help text and in
60
112
  * unknown-model error messages so the available set is always current.
@@ -3,6 +3,10 @@ import {
3
3
  mapGeminiError,
4
4
  } from "./gemini-image-service.js";
5
5
  import { generateImageOpenAI, mapOpenAIError } from "./openai-image-service.js";
6
+ import {
7
+ generateImageOpenRouter,
8
+ mapOpenRouterError,
9
+ } from "./openrouter-image-service.js";
6
10
  import type {
7
11
  ImageGenCredentials,
8
12
  ImageGenerationRequest,
@@ -23,6 +27,8 @@ export function generateImage(
23
27
  return generateImageOpenAI(credentials, request);
24
28
  case "gemini":
25
29
  return generateImageGemini(credentials, request);
30
+ case "openrouter":
31
+ return generateImageOpenRouter(credentials, request);
26
32
  default: {
27
33
  const _exhaustive: never = provider;
28
34
  throw new Error(`Unknown image generation provider: ${_exhaustive}`);
@@ -42,6 +48,8 @@ export function mapImageGenError(
42
48
  return mapOpenAIError(error);
43
49
  case "gemini":
44
50
  return mapGeminiError(error);
51
+ case "openrouter":
52
+ return mapOpenRouterError(error);
45
53
  default: {
46
54
  const _exhaustive: never = provider;
47
55
  throw new Error(`Unknown image generation provider: ${_exhaustive}`);