@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.
- package/openapi.yaml +1 -1
- package/package.json +1 -1
- package/src/__tests__/config-model-image-provider.test.ts +39 -0
- package/src/__tests__/config-schema.test.ts +16 -0
- package/src/__tests__/image-credentials.test.ts +49 -0
- package/src/__tests__/image-service-dispatcher.test.ts +45 -0
- package/src/__tests__/media-generate-image.test.ts +49 -1
- package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +7 -11
- package/src/config/schemas/services.ts +9 -3
- package/src/daemon/conversation-agent-loop.ts +3 -5
- package/src/daemon/handlers/config-model.ts +7 -5
- package/src/media/__tests__/image-models.test.ts +58 -0
- package/src/media/image-credentials.ts +5 -0
- package/src/media/image-models.ts +52 -0
- package/src/media/image-service.ts +8 -0
- package/src/media/openrouter-image-service.test.ts +133 -0
- package/src/media/openrouter-image-service.ts +153 -0
- package/src/media/types.ts +1 -1
- package/src/messaging/providers/discord/send.ts +1 -15
- package/src/messaging/providers/discord/transport.ts +2 -2
- package/src/messaging/providers/telegram-bot/transport.ts +3 -10
- package/src/oauth/oauth-store.ts +1 -3
- package/src/persistence/llm-usage-store.ts +3 -9
- package/src/plugins/defaults/memory/context-search/agent-protocol.ts +2 -9
- package/src/providers/openai/__tests__/google-thought-signature.test.ts +1 -2
- package/src/providers/openai/google-thought-signature.ts +0 -6
- package/src/runtime/routes/__tests__/image-generation-routes.test.ts +97 -0
- package/src/runtime/routes/image-generation-routes.ts +8 -14
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +9 -9
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +1 -5
- 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
|
|
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
|
@@ -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
|
|
119
|
-
//
|
|
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
|
|
123
|
-
if (
|
|
118
|
+
const resolved = resolveRequestedImageModel(modelOverride, svc.provider);
|
|
119
|
+
if (resolved.error) {
|
|
124
120
|
return {
|
|
125
|
-
content:
|
|
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 =
|
|
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 = [
|
|
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"`
|
|
60
|
-
* key. A `mode` key sent by an older client is stripped at
|
|
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`. `
|
|
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:
|
|
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
|
-
|
|
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().
|
|
102
|
-
// managed for every model
|
|
103
|
-
//
|
|
104
|
-
// BYOK provider they may hold no key
|
|
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
|
-
|
|
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}`);
|