@circuitwall/jarela 2.2.0 → 2.2.1
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/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/app-path-routes-manifest.json +2 -2
- package/.next/standalone/.next/build-manifest.json +2 -2
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/api/v1/models/[name]/route.js +61 -20
- package/.next/standalone/.next/server/app/api/v1/models/route.js +5 -3
- package/.next/standalone/.next/server/app/api/v1/providers/[provider]/models/route.js +27 -24
- package/.next/standalone/.next/server/app/api/v1/update/apply/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page.js +313 -251
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app-paths-manifest.json +2 -2
- package/.next/standalone/.next/server/chunks/2471.js +17 -17
- package/.next/standalone/.next/server/chunks/2563.js +24 -50
- package/.next/standalone/.next/server/chunks/3399.js +10695 -10695
- package/.next/standalone/.next/server/chunks/3524.js +5 -33
- package/.next/standalone/.next/server/chunks/424.js +10 -5
- package/.next/standalone/.next/server/chunks/4508.js +2 -2
- package/.next/standalone/.next/server/chunks/46.js +111 -46
- package/.next/standalone/.next/server/chunks/466.js +88 -13
- package/.next/standalone/.next/server/chunks/6649.js +1 -1
- package/.next/standalone/.next/server/chunks/9590.js +108 -46
- package/.next/standalone/.next/server/chunks/9640.js +3 -6
- package/.next/standalone/.next/server/middleware-build-manifest.js +2 -2
- package/.next/standalone/.next/server/middleware.js +104 -45
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/preview-props.json +3 -3
- package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/static/chunks/{1395-f201e3e2e1f57715.js → 1395-a340fb0a345382a4.js} +89 -14
- package/.next/standalone/.next/static/chunks/{4576-13f7546a145a3624.js → 7045-8bc2f14e430ca661.js} +58 -2
- package/.next/standalone/.next/static/chunks/app/{page-c6e972a7fd13dae8.js → page-7017f07f8a7a083c.js} +170 -151
- package/.next/standalone/package.json +1 -1
- package/CHANGELOG.md +8 -0
- package/api/client.models.test.ts +31 -0
- package/api/client.ts +16 -1
- package/api/types.ts +8 -0
- package/app/api/v1/models/[name]/route.test.ts +89 -0
- package/app/api/v1/models/[name]/route.ts +54 -3
- package/app/api/v1/models/route.ts +4 -2
- package/app/api/v1/providers/[provider]/models/route.test.ts +53 -0
- package/app/api/v1/providers/[provider]/models/route.ts +27 -16
- package/components/agents/agent-editor/AdvancedSection.tsx +8 -2
- package/components/agents/agent-editor/ModelSection.tsx +6 -3
- package/components/models/ModelEditor.tsx +1 -1
- package/components/models/ModelsPanel.tsx +34 -8
- package/components/models/model-editor/GitHubCopilotAuth.tsx +1 -1
- package/components/models/model-editor/save-helpers.ts +2 -2
- package/components/models/model-editor/useModelEditorForm.ts +1 -1
- package/components/models/model-editor/useModelSaveHandlers.ts +1 -1
- package/components/setup/wizard/StepCredentials.tsx +1 -1
- package/components/setup/wizard/StepModel.tsx +3 -8
- package/hooks/useModels.ts +17 -1
- package/lib/agents/effective-model.test.ts +45 -0
- package/lib/agents/effective-model.ts +11 -5
- package/lib/agents/llm.ts +2 -2
- package/lib/agents/run-thread.ts +10 -5
- package/lib/agents/warm-summary-background.test.ts +1 -2
- package/lib/agents/warm-summary-background.ts +4 -7
- package/lib/db/migrations.ts +10 -0
- package/lib/health/probes.test.ts +8 -11
- package/lib/health/probes.ts +5 -31
- package/lib/models/editor-payload.ts +1 -1
- package/lib/providers/github-copilot-auth.ts +2 -3
- package/lib/providers/github-copilot.test.ts +27 -0
- package/lib/providers/github-copilot.ts +21 -44
- package/lib/stores/integrations.test.ts +7 -0
- package/lib/stores/integrations.ts +4 -4
- package/lib/stores/model-config.test.ts +88 -0
- package/lib/stores/model-config.ts +90 -8
- package/package.json +1 -1
- /package/.next/standalone/.next/static/{e2NV1wk6mWg3SIeELXevg → NUrC9xax1sp1Q70nlFqS6}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{e2NV1wk6mWg3SIeELXevg → NUrC9xax1sp1Q70nlFqS6}/_ssgManifest.js +0 -0
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
deleteModelConfig,
|
|
5
|
+
getModelConfig,
|
|
6
|
+
isModelConfigEnabled,
|
|
7
|
+
ModelConfigStateError,
|
|
8
|
+
setDefaultModelConfig,
|
|
9
|
+
setModelConfigEnabled,
|
|
10
|
+
upsertModelConfig,
|
|
11
|
+
withModelConfigMutation,
|
|
12
|
+
} from "@/lib/stores/model-config";
|
|
13
|
+
import { withDbTransaction } from "@/lib/db/transaction";
|
|
4
14
|
import { validateBody } from "@/lib/api/responses";
|
|
5
15
|
import { parseJsonSafe } from "@/lib/utils/json";
|
|
6
16
|
import { enrichParamsWithDiscoveredContext } from "@/lib/models/discover-context-window";
|
|
@@ -15,6 +25,17 @@ const PutBody = z.object({
|
|
|
15
25
|
credential_id: z.string().nullable().optional(),
|
|
16
26
|
});
|
|
17
27
|
|
|
28
|
+
// Targeted state changes that must not re-send (and re-process) the whole
|
|
29
|
+
// config. Each request is applied in a single transaction.
|
|
30
|
+
const PatchBody = z.object({
|
|
31
|
+
enabled: z.boolean().optional(),
|
|
32
|
+
is_default: z.literal(true).optional(),
|
|
33
|
+
}).strict().refine((b) => b.enabled !== undefined || b.is_default !== undefined, {
|
|
34
|
+
message: "provide enabled and/or is_default",
|
|
35
|
+
}).refine((b) => !(b.enabled === false && b.is_default === true), {
|
|
36
|
+
message: "a disabled model cannot be the default",
|
|
37
|
+
});
|
|
38
|
+
|
|
18
39
|
// Mirror /api/v1/models GET: never echo plaintext secrets back to the
|
|
19
40
|
// client. Authoritative storage lives in `credentials`; per-model rows
|
|
20
41
|
// retain only non-secret overrides post-migration.
|
|
@@ -64,13 +85,43 @@ export async function PUT(req: NextRequest, { params }: Params) {
|
|
|
64
85
|
body.provider,
|
|
65
86
|
body.model_id,
|
|
66
87
|
enriched,
|
|
67
|
-
body.is_default
|
|
88
|
+
body.is_default,
|
|
68
89
|
body.credential_id ?? null,
|
|
69
90
|
);
|
|
70
|
-
return NextResponse.json(
|
|
91
|
+
return NextResponse.json(toResponse(r));
|
|
71
92
|
});
|
|
72
93
|
}
|
|
73
94
|
|
|
95
|
+
export async function PATCH(req: NextRequest, { params }: Params) {
|
|
96
|
+
const { name } = await params;
|
|
97
|
+
const body = await validateBody(req, PatchBody);
|
|
98
|
+
if (body instanceof NextResponse) return body;
|
|
99
|
+
try {
|
|
100
|
+
const r = withDbTransaction(() => {
|
|
101
|
+
let row = getModelConfig(name);
|
|
102
|
+
if (!row) throw new ModelConfigStateError(`Model config "${name}" not found`, 404);
|
|
103
|
+
if (body.enabled !== undefined) row = setModelConfigEnabled(name, body.enabled);
|
|
104
|
+
if (body.is_default) row = setDefaultModelConfig(name);
|
|
105
|
+
return row;
|
|
106
|
+
});
|
|
107
|
+
return NextResponse.json(toResponse(r));
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if (err instanceof ModelConfigStateError) {
|
|
110
|
+
return NextResponse.json({ error: err.message }, { status: err.status });
|
|
111
|
+
}
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function toResponse(r: NonNullable<ReturnType<typeof getModelConfig>>) {
|
|
117
|
+
return {
|
|
118
|
+
...r,
|
|
119
|
+
params: redactInlineParams(r.params),
|
|
120
|
+
is_default: Boolean(r.is_default),
|
|
121
|
+
enabled: isModelConfigEnabled(r),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
74
125
|
export async function DELETE(_req: NextRequest, { params }: Params) {
|
|
75
126
|
const { name } = await params;
|
|
76
127
|
return withModelConfigMutation(name, () => {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { NextRequest, NextResponse } from "next/server";
|
|
9
9
|
import { z } from "zod";
|
|
10
|
-
import { getModelConfig, listModelConfigs, upsertModelConfig, withModelConfigMutation } from "@/lib/stores/model-config";
|
|
10
|
+
import { getModelConfig, isModelConfigEnabled, listModelConfigs, upsertModelConfig, withModelConfigMutation } from "@/lib/stores/model-config";
|
|
11
11
|
import { createdResponse, cachedJson, validateBody } from "@/lib/api/responses";
|
|
12
12
|
import { parseJsonSafe } from "@/lib/utils/json";
|
|
13
13
|
import { enrichParamsWithDiscoveredContext } from "@/lib/models/discover-context-window";
|
|
@@ -65,6 +65,7 @@ export function GET() {
|
|
|
65
65
|
...r,
|
|
66
66
|
params: redactInlineParams(r.params),
|
|
67
67
|
is_default: Boolean(r.is_default),
|
|
68
|
+
enabled: isModelConfigEnabled(r),
|
|
68
69
|
})), 15, 60);
|
|
69
70
|
}
|
|
70
71
|
|
|
@@ -79,13 +80,14 @@ export async function POST(req: NextRequest) {
|
|
|
79
80
|
body.provider,
|
|
80
81
|
body.model_id,
|
|
81
82
|
enriched,
|
|
82
|
-
body.is_default
|
|
83
|
+
body.is_default,
|
|
83
84
|
body.credential_id ?? null,
|
|
84
85
|
);
|
|
85
86
|
return createdResponse({
|
|
86
87
|
...r,
|
|
87
88
|
params: redactInlineParams(r.params),
|
|
88
89
|
is_default: Boolean(r.is_default),
|
|
90
|
+
enabled: isModelConfigEnabled(r),
|
|
89
91
|
});
|
|
90
92
|
});
|
|
91
93
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { NextRequest } from "next/server";
|
|
6
|
+
|
|
7
|
+
const tmpRoot = mkdtempSync(join(tmpdir(), "jarela-test-provider-models-"));
|
|
8
|
+
process.env.JARELA_DB_DIR = tmpRoot;
|
|
9
|
+
|
|
10
|
+
const { upsertModelConfig, deleteModelConfig } = await import("@/lib/stores/model-config");
|
|
11
|
+
const { GET } = await import("./route");
|
|
12
|
+
|
|
13
|
+
const params = { params: Promise.resolve({ provider: "github-copilot" }) };
|
|
14
|
+
const request = () => new NextRequest("http://localhost/api/v1/providers/github-copilot/models?fresh=1");
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
deleteModelConfig("copilot-config");
|
|
18
|
+
vi.unstubAllEnvs();
|
|
19
|
+
vi.unstubAllGlobals();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterAll(() => {
|
|
23
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* */ }
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("provider model catalogs", () => {
|
|
27
|
+
it("rejects a retired PAT config as a configuration error", async () => {
|
|
28
|
+
upsertModelConfig("copilot-config", "github-copilot", "gpt-4o", {
|
|
29
|
+
api_key: "github_pat_synthetic",
|
|
30
|
+
}, true);
|
|
31
|
+
|
|
32
|
+
const response = await GET(request(), params);
|
|
33
|
+
|
|
34
|
+
expect(response.status).toBe(400);
|
|
35
|
+
await expect(response.json()).resolves.toMatchObject({
|
|
36
|
+
error: expect.stringMatching(/PAT.*GitHub Models.*retired/i),
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("ignores a GITHUB_TOKEN PAT and returns the curated Copilot list", async () => {
|
|
41
|
+
upsertModelConfig("copilot-config", "github-copilot", "gpt-4o", {}, true);
|
|
42
|
+
vi.stubEnv("GITHUB_TOKEN", "ghp_synthetic");
|
|
43
|
+
const fetch = vi.fn();
|
|
44
|
+
vi.stubGlobal("fetch", fetch);
|
|
45
|
+
|
|
46
|
+
const response = await GET(request(), params);
|
|
47
|
+
|
|
48
|
+
expect(response.status).toBe(200);
|
|
49
|
+
const models = await response.json() as Array<{ id: string }>;
|
|
50
|
+
expect(models.length).toBeGreaterThan(0);
|
|
51
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -56,10 +56,19 @@ export async function GET(req: NextRequest, { params }: Params) {
|
|
|
56
56
|
},
|
|
57
57
|
});
|
|
58
58
|
} catch (err) {
|
|
59
|
-
return
|
|
59
|
+
return catalogErrorResponse(err);
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// Raised when the saved configuration itself is unusable, so callers get a
|
|
64
|
+
// 4xx they can act on instead of a generic server error.
|
|
65
|
+
class CatalogConfigError extends Error {}
|
|
66
|
+
|
|
67
|
+
function catalogErrorResponse(err: unknown): NextResponse {
|
|
68
|
+
const status = err instanceof CatalogConfigError ? 400 : 500;
|
|
69
|
+
return NextResponse.json({ error: String(err) }, { status });
|
|
70
|
+
}
|
|
71
|
+
|
|
63
72
|
// POST allows the editor to ship the in-form credentials (api_key /
|
|
64
73
|
// base_url / extra_headers) before the user has saved them. We never cache
|
|
65
74
|
// these responses since they're keyed on user-supplied secrets.
|
|
@@ -71,7 +80,7 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|
|
71
80
|
const models = await fetchCatalog(provider, body.params);
|
|
72
81
|
return NextResponse.json(models, { headers: { "Cache-Control": "no-store" } });
|
|
73
82
|
} catch (err) {
|
|
74
|
-
return
|
|
83
|
+
return catalogErrorResponse(err);
|
|
75
84
|
}
|
|
76
85
|
}
|
|
77
86
|
|
|
@@ -162,13 +171,8 @@ async function fetchOpenAICatalog(): Promise<CatalogModel[]> {
|
|
|
162
171
|
// ── GitHub Copilot ────────────────────────────────────────────────────────────
|
|
163
172
|
|
|
164
173
|
async function fetchGitHubCopilotCatalog(): Promise<CatalogModel[]> {
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
// confusing when the user is configuring the "github-copilot" provider
|
|
168
|
-
// specifically. We mirror what Copilot Chat actually serves: its own
|
|
169
|
-
// /models endpoint when we have an exchangeable credential, else a curated
|
|
170
|
-
// fallback list. Users who want the raw GitHub Models catalog can type the
|
|
171
|
-
// namespaced model id directly into the Model ID field.
|
|
174
|
+
// Use Copilot Chat's account-specific /models endpoint when an exchangeable
|
|
175
|
+
// credential is available, otherwise return a curated fallback list.
|
|
172
176
|
|
|
173
177
|
// 1. Prefer the in-app device-flow OAuth token — it can be exchanged.
|
|
174
178
|
const { getStoredOAuthToken } = await import("@/lib/providers/github-copilot-auth");
|
|
@@ -193,8 +197,16 @@ async function fetchGitHubCopilotCatalog(): Promise<CatalogModel[]> {
|
|
|
193
197
|
if (list.length > 0) return list;
|
|
194
198
|
} catch { /* fall through */ }
|
|
195
199
|
}
|
|
196
|
-
|
|
197
|
-
|
|
200
|
+
// Only the Copilot config's own api_key is considered: GITHUB_TOKEN belongs
|
|
201
|
+
// to the GitHub integration and is usually a PAT unrelated to Copilot.
|
|
202
|
+
const apiKey = (params.api_key as string | undefined)?.trim();
|
|
203
|
+
if (apiKey && isLikelyGitHubPat(apiKey)) {
|
|
204
|
+
throw new CatalogConfigError(
|
|
205
|
+
"GitHub Copilot: PAT model discovery used GitHub Models, which has been retired. " +
|
|
206
|
+
"Use device-flow sign-in in the Models panel or set copilot_session_token.",
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
if (apiKey) {
|
|
198
210
|
try {
|
|
199
211
|
const sessionToken = await exchangeCopilotSessionToken(apiKey);
|
|
200
212
|
const list = await fetchCopilotChatCatalog(sessionToken);
|
|
@@ -203,8 +215,7 @@ async function fetchGitHubCopilotCatalog(): Promise<CatalogModel[]> {
|
|
|
203
215
|
}
|
|
204
216
|
}
|
|
205
217
|
|
|
206
|
-
// 3. Fallback: curated known-models list
|
|
207
|
-
// model IDs are valid on both Models API and Copilot Chat.
|
|
218
|
+
// 3. Fallback: curated known-models list when no credential can be exchanged.
|
|
208
219
|
return githubCopilotKnownModels();
|
|
209
220
|
}
|
|
210
221
|
|
|
@@ -241,13 +252,13 @@ async function fetchCopilotChatCatalog(sessionToken: string): Promise<CatalogMod
|
|
|
241
252
|
}
|
|
242
253
|
|
|
243
254
|
function isLikelyGitHubPat(token: string): boolean {
|
|
244
|
-
return token.startsWith("ghp_") || token.startsWith("github_pat_")
|
|
255
|
+
return token.startsWith("ghp_") || token.startsWith("github_pat_");
|
|
245
256
|
}
|
|
246
257
|
|
|
247
|
-
async function exchangeCopilotSessionToken(
|
|
258
|
+
async function exchangeCopilotSessionToken(githubToken: string): Promise<string> {
|
|
248
259
|
const res = await fetch("https://api.github.com/copilot_internal/v2/token", {
|
|
249
260
|
headers: {
|
|
250
|
-
Authorization: `token ${
|
|
261
|
+
Authorization: `token ${githubToken}`,
|
|
251
262
|
"User-Agent": "Jarela/1.0",
|
|
252
263
|
},
|
|
253
264
|
});
|
|
@@ -9,6 +9,7 @@ import { VoiceFields } from "./VoiceFields";
|
|
|
9
9
|
import { ToolCredentialsSection } from "./ToolCredentialsSection";
|
|
10
10
|
import { Select } from "@/components/ui/Select";
|
|
11
11
|
import { modelSupportsImages } from "@/lib/providers/capabilities";
|
|
12
|
+
import { isModelEnabled } from "@/lib/agents/effective-model";
|
|
12
13
|
import { SelectedModelDescription } from "./ModelSection";
|
|
13
14
|
|
|
14
15
|
interface Props {
|
|
@@ -31,15 +32,20 @@ export function AdvancedSection({ form, models, integrations, isFullMode, onClos
|
|
|
31
32
|
</p>
|
|
32
33
|
<Select value={form.modelConfigName} onChange={(e) => form.setModelConfigName(e.target.value)}>
|
|
33
34
|
<option value="">Automatic routing (recommended)</option>
|
|
34
|
-
{models.map((m) => {
|
|
35
|
+
{models.filter((m) => isModelEnabled(m) || m.name === form.modelConfigName).map((m) => {
|
|
35
36
|
const vision = modelSupportsImages(m.provider, m.model_id);
|
|
36
37
|
return (
|
|
37
38
|
<option key={m.name} value={m.name}>
|
|
38
|
-
{vision ? "📷 " : ""}{m.name} · {m.provider} / {m.model_id}
|
|
39
|
+
{vision ? "📷 " : ""}{m.name} · {m.provider} / {m.model_id}{isModelEnabled(m) ? "" : " (disabled)"}
|
|
39
40
|
</option>
|
|
40
41
|
);
|
|
41
42
|
})}
|
|
42
43
|
</Select>
|
|
44
|
+
{selectedModel && !isModelEnabled(selectedModel) && (
|
|
45
|
+
<p className="text-[11px] text-amber-700 dark:text-amber-400">
|
|
46
|
+
This model is disabled, so the agent uses automatic routing until it is enabled again.
|
|
47
|
+
</p>
|
|
48
|
+
)}
|
|
43
49
|
{selectedModel && <SelectedModelDescription model={selectedModel} prefix="Forced" />}
|
|
44
50
|
</div>
|
|
45
51
|
<Divider />
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ModelConfig } from "@/api/types";
|
|
2
|
+
import { isModelEnabled } from "@/lib/agents/effective-model";
|
|
2
3
|
import { isProviderClassified, modelSupportsImages } from "@/lib/providers/capabilities";
|
|
3
4
|
import { CapBadges } from "@/components/models/CapBadges";
|
|
4
5
|
import { ProviderLogo } from "@/components/models/ProviderLogo";
|
|
@@ -19,8 +20,10 @@ interface Props {
|
|
|
19
20
|
|
|
20
21
|
export function ModelSection({ form, models, integrations, onClose }: Props) {
|
|
21
22
|
const { dispatch } = useAppContext();
|
|
22
|
-
const
|
|
23
|
-
const
|
|
23
|
+
const enabledModels = models.filter(isModelEnabled);
|
|
24
|
+
const defaultModel = enabledModels.find((m) => m.is_default) ?? enabledModels[0];
|
|
25
|
+
const forcedModel = enabledModels.find((m) => m.name === form.modelConfigName);
|
|
26
|
+
const selectedModel = forcedModel ?? defaultModel;
|
|
24
27
|
const readiness = computeFeatureReadiness({
|
|
25
28
|
models,
|
|
26
29
|
integrations,
|
|
@@ -48,7 +51,7 @@ export function ModelSection({ form, models, integrations, onClose }: Props) {
|
|
|
48
51
|
) : (
|
|
49
52
|
<p className="text-[11px] text-amber-700 dark:text-amber-400">No default fallback model is configured yet.</p>
|
|
50
53
|
)}
|
|
51
|
-
{
|
|
54
|
+
{forcedModel && (
|
|
52
55
|
<p className="text-[11px] text-sky-700 dark:text-sky-400">
|
|
53
56
|
This agent currently has a forced model override. Clear it in Advanced settings to return to router-driven selection.
|
|
54
57
|
</p>
|
|
@@ -9,7 +9,7 @@ import { ModelEditorBody, ModelEditorOverlays } from "./model-editor/ModelEditor
|
|
|
9
9
|
|
|
10
10
|
interface Props {
|
|
11
11
|
model?: ModelConfig;
|
|
12
|
-
onSave: (name: string, data: Omit<ModelConfig, "name" | "created_at" | "updated_at">) => Promise<void>;
|
|
12
|
+
onSave: (name: string, data: Omit<ModelConfig, "name" | "enabled" | "created_at" | "updated_at">) => Promise<void>;
|
|
13
13
|
onClose: () => void;
|
|
14
14
|
}
|
|
15
15
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { ArrowUpRight, Cpu, Plus, Star, Trash2 } from "lucide-react";
|
|
2
|
+
import { ArrowUpRight, Cpu, Plus, Power, PowerOff, Star, Trash2 } from "lucide-react";
|
|
3
3
|
import { HeaderAction, PanelHeader } from "@/components/ui/PanelHeader";
|
|
4
4
|
import { useEffect, useRef, useState } from "react";
|
|
5
5
|
import type { ModelConfig } from "@/api/types";
|
|
@@ -37,7 +37,7 @@ interface EnvEntry {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
export function ModelsPanel() {
|
|
40
|
-
const { models, assignments, loading, create, update, remove, refresh } = useModels();
|
|
40
|
+
const { models, assignments, loading, create, update, setDefault, setEnabled, remove, refresh } = useModels();
|
|
41
41
|
const { agents, loading: agentsLoading } = useAgents();
|
|
42
42
|
const strategyOverrides = agents.filter((agent) => agent.usage_strategy != null);
|
|
43
43
|
const [editing, setEditing] = useState<ModelConfig | null | "new">(null);
|
|
@@ -93,14 +93,28 @@ export function ModelsPanel() {
|
|
|
93
93
|
return () => { cancelled = true; };
|
|
94
94
|
}, []);
|
|
95
95
|
|
|
96
|
-
async function handleSave(name: string, data: Omit<ModelConfig, "name" | "created_at" | "updated_at">) {
|
|
96
|
+
async function handleSave(name: string, data: Omit<ModelConfig, "name" | "enabled" | "created_at" | "updated_at">) {
|
|
97
97
|
if (editing === "new") await create(name, data);
|
|
98
98
|
else if (editing) await update(name, data);
|
|
99
99
|
refresh();
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
async function handleSetDefault(m: ModelConfig) {
|
|
103
|
-
|
|
103
|
+
setDeleteError(null);
|
|
104
|
+
try {
|
|
105
|
+
await setDefault(m.name);
|
|
106
|
+
} catch (e) {
|
|
107
|
+
setDeleteError(`Could not set "${m.name}" as default: ${errorMessage(e)}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function handleToggleEnabled(m: ModelConfig) {
|
|
112
|
+
setDeleteError(null);
|
|
113
|
+
try {
|
|
114
|
+
await setEnabled(m.name, !m.enabled);
|
|
115
|
+
} catch (e) {
|
|
116
|
+
setDeleteError(`Could not ${m.enabled ? "disable" : "enable"} "${m.name}": ${errorMessage(e)}`);
|
|
117
|
+
}
|
|
104
118
|
}
|
|
105
119
|
|
|
106
120
|
async function handleRemove(name: string) {
|
|
@@ -283,14 +297,17 @@ export function ModelsPanel() {
|
|
|
283
297
|
{models.map((m) => {
|
|
284
298
|
const inUse = assignments.some((a) => a.model_config_name === m.name);
|
|
285
299
|
return (
|
|
286
|
-
<div key={m.name} data-deep-link-id={m.name} onClick={() => setEditing(m)} className=
|
|
300
|
+
<div key={m.name} data-deep-link-id={m.name} onClick={() => setEditing(m)} className={`flex items-center gap-3 py-2.5 border-b border-border/60 group cursor-pointer hover:bg-surface-3/30 transition-colors ${m.enabled ? "" : "opacity-60"}`}>
|
|
287
301
|
<span className="shrink-0 text-fg-subtle">
|
|
288
302
|
<ProviderLogo name={m.provider} size={22} />
|
|
289
303
|
</span>
|
|
290
304
|
<div className="flex-1 min-w-0">
|
|
291
305
|
<div className="flex items-center gap-2 mb-0.5">
|
|
292
|
-
<span className=
|
|
306
|
+
<span className={`text-sm font-medium ${m.enabled ? "text-fg" : "text-fg-subtle line-through"}`}>{m.name}</span>
|
|
293
307
|
{m.is_default && <Star size={11} className="text-yellow-700 dark:text-yellow-400 fill-yellow-400 shrink-0" />}
|
|
308
|
+
{!m.enabled && (
|
|
309
|
+
<span className="text-xs px-1.5 py-0.5 rounded border bg-surface-2 text-fg-muted border-border">disabled</span>
|
|
310
|
+
)}
|
|
294
311
|
<span className={`text-xs px-1.5 py-0.5 rounded border ${PROVIDER_COLORS[m.provider] ?? "bg-surface-2 text-fg-muted border-border"}`}>
|
|
295
312
|
{m.provider}
|
|
296
313
|
</span>
|
|
@@ -301,11 +318,20 @@ export function ModelsPanel() {
|
|
|
301
318
|
</div>
|
|
302
319
|
</div>
|
|
303
320
|
<div className="flex gap-1 opacity-40 group-hover:opacity-100 pointer-coarse:opacity-100 transition-opacity shrink-0">
|
|
304
|
-
{!m.is_default && (
|
|
305
|
-
<button onClick={(e) => { e.stopPropagation(); handleSetDefault(m); }} className="p-1 text-fg-subtle hover:text-yellow-700 dark:hover:text-yellow-400 transition-colors" title="Set as default">
|
|
321
|
+
{!m.is_default && m.enabled && (
|
|
322
|
+
<button onClick={(e) => { e.stopPropagation(); void handleSetDefault(m); }} className="p-1 text-fg-subtle hover:text-yellow-700 dark:hover:text-yellow-400 transition-colors" title="Set as default">
|
|
306
323
|
<Star size={13} />
|
|
307
324
|
</button>
|
|
308
325
|
)}
|
|
326
|
+
<button
|
|
327
|
+
onClick={(e) => { e.stopPropagation(); void handleToggleEnabled(m); }}
|
|
328
|
+
className="p-1 text-fg-subtle hover:text-fg transition-colors"
|
|
329
|
+
title={m.enabled ? "Disable; hide from model selection" : "Enable; show in model selection"}
|
|
330
|
+
aria-label={m.enabled ? `Disable ${m.name}` : `Enable ${m.name}`}
|
|
331
|
+
aria-pressed={!m.enabled}
|
|
332
|
+
>
|
|
333
|
+
{m.enabled ? <Power size={13} /> : <PowerOff size={13} />}
|
|
334
|
+
</button>
|
|
309
335
|
<button
|
|
310
336
|
onClick={(e) => { e.stopPropagation(); handleRemove(m.name); }}
|
|
311
337
|
className="p-1 text-fg-subtle hover:text-red-700 dark:hover:text-red-400 transition-colors"
|
|
@@ -27,7 +27,7 @@ function AuthHeader({ status, polling, onSignIn, onSignOut }: HeaderProps) {
|
|
|
27
27
|
<div className="text-fg-muted">
|
|
28
28
|
<strong>GitHub Copilot sign-in</strong>
|
|
29
29
|
<p className="text-fg-faint mt-0.5">
|
|
30
|
-
|
|
30
|
+
Required for Copilot models; GitHub PAT authentication is no longer supported.
|
|
31
31
|
</p>
|
|
32
32
|
</div>
|
|
33
33
|
{status?.signed_in ? (
|
|
@@ -33,7 +33,7 @@ export function buildPayload(form: ModelEditorForm) {
|
|
|
33
33
|
|
|
34
34
|
export async function runProbeStep(
|
|
35
35
|
form: ModelEditorForm,
|
|
36
|
-
payload: Omit<ModelConfig, "name" | "created_at" | "updated_at">,
|
|
36
|
+
payload: Omit<ModelConfig, "name" | "enabled" | "created_at" | "updated_at">,
|
|
37
37
|
): Promise<boolean> {
|
|
38
38
|
const probe = await api.models
|
|
39
39
|
.probe(form.provider, payload.model_id, payload.params as Record<string, unknown>, undefined, form.credentialId ?? undefined)
|
|
@@ -50,7 +50,7 @@ export async function runProbeStep(
|
|
|
50
50
|
|
|
51
51
|
export function shouldStageShrinkConfirm(
|
|
52
52
|
form: ModelEditorForm,
|
|
53
|
-
result: { name: string; payload: Omit<ModelConfig, "name" | "created_at" | "updated_at"> },
|
|
53
|
+
result: { name: string; payload: Omit<ModelConfig, "name" | "enabled" | "created_at" | "updated_at"> },
|
|
54
54
|
): boolean {
|
|
55
55
|
if (!form.model) return false;
|
|
56
56
|
const oldCtx = Number(form.model.params.context_window_tokens) || 0;
|
|
@@ -9,7 +9,7 @@ export type ShrinkPending =
|
|
|
9
9
|
| {
|
|
10
10
|
oldSnapshot: { provider: string; model_id: string; params: Record<string, unknown> };
|
|
11
11
|
payloadName: string;
|
|
12
|
-
payload: Omit<ModelConfig, "name" | "created_at" | "updated_at">;
|
|
12
|
+
payload: Omit<ModelConfig, "name" | "enabled" | "created_at" | "updated_at">;
|
|
13
13
|
};
|
|
14
14
|
|
|
15
15
|
export type ProbeResult = { ok: boolean; error?: string } | null;
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
|
|
13
13
|
interface Args {
|
|
14
14
|
form: ModelEditorForm;
|
|
15
|
-
onSave: (name: string, data: Omit<ModelConfig, "name" | "created_at" | "updated_at">) => Promise<void>;
|
|
15
|
+
onSave: (name: string, data: Omit<ModelConfig, "name" | "enabled" | "created_at" | "updated_at">) => Promise<void>;
|
|
16
16
|
onClose: () => void;
|
|
17
17
|
}
|
|
18
18
|
|
|
@@ -200,7 +200,7 @@ export function StepCredentials({ definitions, integrations, onChanged }: StepCr
|
|
|
200
200
|
icon={<KeyRound size={18} />}
|
|
201
201
|
eyebrow="Step 2 · Credentials"
|
|
202
202
|
title="Connect provider credentials in the UI first"
|
|
203
|
-
description="Use the built-in credential forms as the main setup path
|
|
203
|
+
description="Use the built-in credential forms as the main setup path for Claude, GPT, Gemini, and other providers. GitHub Copilot uses device-flow sign-in in the Models panel."
|
|
204
204
|
>
|
|
205
205
|
<div className="rounded-xl border border-accent/30 bg-accent/5 px-3 py-2.5 text-xs text-fg">
|
|
206
206
|
<p className="font-medium">Recommended path</p>
|
|
@@ -21,7 +21,7 @@ export function StepModel({ models, onChanged }: StepModelProps) {
|
|
|
21
21
|
|
|
22
22
|
async function handleSave(
|
|
23
23
|
name: string,
|
|
24
|
-
data: Omit<ModelConfig, "name" | "created_at" | "updated_at">,
|
|
24
|
+
data: Omit<ModelConfig, "name" | "enabled" | "created_at" | "updated_at">,
|
|
25
25
|
) {
|
|
26
26
|
setError(null);
|
|
27
27
|
try {
|
|
@@ -35,12 +35,7 @@ export function StepModel({ models, onChanged }: StepModelProps) {
|
|
|
35
35
|
|
|
36
36
|
async function handleSetDefault(m: ModelConfig) {
|
|
37
37
|
try {
|
|
38
|
-
await api.models.
|
|
39
|
-
provider: m.provider,
|
|
40
|
-
model_id: m.model_id,
|
|
41
|
-
params: m.params,
|
|
42
|
-
is_default: true,
|
|
43
|
-
});
|
|
38
|
+
await api.models.patch(m.name, { is_default: true });
|
|
44
39
|
onChanged();
|
|
45
40
|
} catch (e) {
|
|
46
41
|
setError(errorMessage(e));
|
|
@@ -114,7 +109,7 @@ export function StepModel({ models, onChanged }: StepModelProps) {
|
|
|
114
109
|
<p className="truncate text-[11px] text-fg-faint">{m.model_id}</p>
|
|
115
110
|
</div>
|
|
116
111
|
<div className="flex shrink-0 items-center gap-0.5">
|
|
117
|
-
{!m.is_default && (
|
|
112
|
+
{!m.is_default && m.enabled !== false && (
|
|
118
113
|
<button
|
|
119
114
|
type="button"
|
|
120
115
|
onClick={(e) => { e.stopPropagation(); handleSetDefault(m); }}
|
package/hooks/useModels.ts
CHANGED
|
@@ -15,6 +15,8 @@ export function useModels(): UnifiedHookResult<
|
|
|
15
15
|
refresh: () => Promise<void>;
|
|
16
16
|
create: (name: string, data: ModelConfigIn) => Promise<ModelConfig>;
|
|
17
17
|
update: (name: string, data: ModelConfigIn) => Promise<ModelConfig>;
|
|
18
|
+
setDefault: (name: string) => Promise<ModelConfig>;
|
|
19
|
+
setEnabled: (name: string, enabled: boolean) => Promise<ModelConfig>;
|
|
18
20
|
remove: (name: string) => Promise<void>;
|
|
19
21
|
assign: (agent_id: string, model_config_name: string, tool_policy?: ToolPolicy) => Promise<TaskAssignment>;
|
|
20
22
|
unassign: (agent_id: string) => Promise<void>;
|
|
@@ -61,6 +63,18 @@ export function useModels(): UnifiedHookResult<
|
|
|
61
63
|
return m;
|
|
62
64
|
}, [refreshModels]);
|
|
63
65
|
|
|
66
|
+
const setDefault = useCallback(async (name: string) => {
|
|
67
|
+
const m = await api.models.patch(name, { is_default: true });
|
|
68
|
+
await refreshModels();
|
|
69
|
+
return m;
|
|
70
|
+
}, [refreshModels]);
|
|
71
|
+
|
|
72
|
+
const setEnabled = useCallback(async (name: string, enabled: boolean) => {
|
|
73
|
+
const m = await api.models.patch(name, { enabled });
|
|
74
|
+
await refreshModels();
|
|
75
|
+
return m;
|
|
76
|
+
}, [refreshModels]);
|
|
77
|
+
|
|
64
78
|
const remove = useCallback(async (name: string): Promise<void> => {
|
|
65
79
|
await api.models.delete(name);
|
|
66
80
|
await refreshModels();
|
|
@@ -82,7 +96,7 @@ export function useModels(): UnifiedHookResult<
|
|
|
82
96
|
}, [setAssignments]);
|
|
83
97
|
|
|
84
98
|
const state = { models, assignments, loading, error };
|
|
85
|
-
const commands = { refresh, create, update, remove, assign, unassign };
|
|
99
|
+
const commands = { refresh, create, update, setDefault, setEnabled, remove, assign, unassign };
|
|
86
100
|
|
|
87
101
|
return {
|
|
88
102
|
state,
|
|
@@ -94,6 +108,8 @@ export function useModels(): UnifiedHookResult<
|
|
|
94
108
|
refresh,
|
|
95
109
|
create,
|
|
96
110
|
update,
|
|
111
|
+
setDefault,
|
|
112
|
+
setEnabled,
|
|
97
113
|
remove,
|
|
98
114
|
assign,
|
|
99
115
|
unassign,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { ModelConfig } from "@/api/types";
|
|
3
|
+
import { agentModelStatus, isModelEnabled, resolveAgentModel } from "./effective-model";
|
|
4
|
+
|
|
5
|
+
function model(name: string, extra: Partial<ModelConfig> = {}): ModelConfig {
|
|
6
|
+
return {
|
|
7
|
+
name,
|
|
8
|
+
provider: "mock",
|
|
9
|
+
model_id: `${name}-id`,
|
|
10
|
+
params: {},
|
|
11
|
+
is_default: false,
|
|
12
|
+
enabled: true,
|
|
13
|
+
created_at: "",
|
|
14
|
+
updated_at: "",
|
|
15
|
+
...extra,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("resolveAgentModel", () => {
|
|
20
|
+
it("uses the explicit model when it is enabled", () => {
|
|
21
|
+
const models = [model("a", { is_default: true }), model("b")];
|
|
22
|
+
expect(resolveAgentModel({ model_config_name: "b" }, models)?.name).toBe("b");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("falls back to the default when the explicit model is disabled", () => {
|
|
26
|
+
const models = [model("a", { is_default: true }), model("b", { enabled: false })];
|
|
27
|
+
expect(resolveAgentModel({ model_config_name: "b" }, models)?.name).toBe("a");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("never picks a disabled model as the implicit default", () => {
|
|
31
|
+
const models = [model("a", { enabled: false }), model("b")];
|
|
32
|
+
expect(resolveAgentModel(null, models)?.name).toBe("b");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("reports no-model when every config is disabled", () => {
|
|
36
|
+
const models = [model("a", { enabled: false, is_default: true })];
|
|
37
|
+
expect(agentModelStatus({ model_config_name: null }, models)).toEqual({ state: "no-model" });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("treats rows without the enabled field as enabled", () => {
|
|
41
|
+
const legacy = { ...model("a") } as Partial<ModelConfig>;
|
|
42
|
+
delete legacy.enabled;
|
|
43
|
+
expect(isModelEnabled(legacy as ModelConfig)).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -3,9 +3,9 @@ import type { AgentConfig, ModelConfig } from "@/api/types";
|
|
|
3
3
|
/**
|
|
4
4
|
* Resolve which `ModelConfig` will actually be used for an agent at runtime.
|
|
5
5
|
*
|
|
6
|
-
* Mirrors what `lib/agents/llm.ts` does server-side: when an agent has
|
|
7
|
-
* `model_config_name
|
|
8
|
-
*
|
|
6
|
+
* Mirrors what `lib/agents/llm.ts` does server-side: when an agent has an
|
|
7
|
+
* enabled `model_config_name`, that wins; otherwise the workspace-wide
|
|
8
|
+
* default among enabled models is used. Both client (BridgeEditor, AgentEditor capability hints)
|
|
9
9
|
* and server need the same logic — this is the single source of truth.
|
|
10
10
|
*
|
|
11
11
|
* Returns `null` only when neither the requested config nor the default
|
|
@@ -15,9 +15,15 @@ export function resolveAgentModel(
|
|
|
15
15
|
agent: Pick<AgentConfig, "model_config_name"> | null | undefined,
|
|
16
16
|
models: readonly ModelConfig[],
|
|
17
17
|
): ModelConfig | null {
|
|
18
|
-
const
|
|
18
|
+
const enabled = models.filter(isModelEnabled);
|
|
19
|
+
const defaultModel = enabled.find((m) => m.is_default) ?? enabled[0] ?? null;
|
|
19
20
|
if (!agent?.model_config_name) return defaultModel;
|
|
20
|
-
return
|
|
21
|
+
return enabled.find((m) => m.name === agent.model_config_name) ?? defaultModel;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Disabled configs stay stored but are skipped by runtime resolution. */
|
|
25
|
+
export function isModelEnabled(model: Pick<ModelConfig, "enabled">): boolean {
|
|
26
|
+
return model.enabled !== false;
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
export type AgentModelStatus =
|
package/lib/agents/llm.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
import type { BaseMessage } from "@langchain/core/messages";
|
|
10
10
|
import { getProvider } from "@/lib/providers";
|
|
11
11
|
import { ProviderAuthError, isAuthErrorMessage, isRetryableProviderStreamError } from "@/lib/providers/errors";
|
|
12
|
-
import {
|
|
12
|
+
import { getModelParams, resolveRunnableModelConfig, upsertModelConfig } from "@/lib/stores/model-config";
|
|
13
13
|
import { getAllToolsAsync } from "@/lib/tools";
|
|
14
14
|
import { JarelaChatModel } from "@/lib/providers/jarela-chat-model";
|
|
15
15
|
import { SqliteMemoryStore } from "@/lib/stores/langgraph-store";
|
|
@@ -226,7 +226,7 @@ async function* streamWithConfigImpl(
|
|
|
226
226
|
const runCfg = options?.agent_run_config;
|
|
227
227
|
|
|
228
228
|
const cfgName = runCfg?.model_config_name ?? null;
|
|
229
|
-
const cfg = (cfgName
|
|
229
|
+
const cfg = resolveRunnableModelConfig(cfgName);
|
|
230
230
|
|
|
231
231
|
if (!cfg) {
|
|
232
232
|
yield { type: "error", data: { message: "No model configured. Add a model in the Models panel.", code: "no_model" } };
|