@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.
Files changed (86) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-path-routes-manifest.json +2 -2
  3. package/.next/standalone/.next/build-manifest.json +2 -2
  4. package/.next/standalone/.next/prerender-manifest.json +3 -3
  5. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  12. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  13. package/.next/standalone/.next/server/app/_not-found.rsc +1 -1
  14. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  15. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  16. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  18. package/.next/standalone/.next/server/app/api/v1/models/[name]/route.js +61 -20
  19. package/.next/standalone/.next/server/app/api/v1/models/route.js +5 -3
  20. package/.next/standalone/.next/server/app/api/v1/providers/[provider]/models/route.js +27 -24
  21. package/.next/standalone/.next/server/app/api/v1/update/apply/route.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/page.js +313 -251
  23. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  24. package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
  25. package/.next/standalone/.next/server/app-paths-manifest.json +2 -2
  26. package/.next/standalone/.next/server/chunks/2471.js +17 -17
  27. package/.next/standalone/.next/server/chunks/2563.js +24 -50
  28. package/.next/standalone/.next/server/chunks/3399.js +10695 -10695
  29. package/.next/standalone/.next/server/chunks/3524.js +5 -33
  30. package/.next/standalone/.next/server/chunks/424.js +10 -5
  31. package/.next/standalone/.next/server/chunks/4508.js +2 -2
  32. package/.next/standalone/.next/server/chunks/46.js +111 -46
  33. package/.next/standalone/.next/server/chunks/466.js +88 -13
  34. package/.next/standalone/.next/server/chunks/6649.js +1 -1
  35. package/.next/standalone/.next/server/chunks/9590.js +108 -46
  36. package/.next/standalone/.next/server/chunks/9640.js +3 -6
  37. package/.next/standalone/.next/server/middleware-build-manifest.js +2 -2
  38. package/.next/standalone/.next/server/middleware.js +104 -45
  39. package/.next/standalone/.next/server/pages/404.html +1 -1
  40. package/.next/standalone/.next/server/pages/500.html +1 -1
  41. package/.next/standalone/.next/server/preview-props.json +3 -3
  42. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  43. package/.next/standalone/.next/static/chunks/{1395-f201e3e2e1f57715.js → 1395-a340fb0a345382a4.js} +89 -14
  44. package/.next/standalone/.next/static/chunks/{4576-13f7546a145a3624.js → 7045-8bc2f14e430ca661.js} +58 -2
  45. package/.next/standalone/.next/static/chunks/app/{page-c6e972a7fd13dae8.js → page-7017f07f8a7a083c.js} +170 -151
  46. package/.next/standalone/package.json +1 -1
  47. package/CHANGELOG.md +8 -0
  48. package/api/client.models.test.ts +31 -0
  49. package/api/client.ts +16 -1
  50. package/api/types.ts +8 -0
  51. package/app/api/v1/models/[name]/route.test.ts +89 -0
  52. package/app/api/v1/models/[name]/route.ts +54 -3
  53. package/app/api/v1/models/route.ts +4 -2
  54. package/app/api/v1/providers/[provider]/models/route.test.ts +53 -0
  55. package/app/api/v1/providers/[provider]/models/route.ts +27 -16
  56. package/components/agents/agent-editor/AdvancedSection.tsx +8 -2
  57. package/components/agents/agent-editor/ModelSection.tsx +6 -3
  58. package/components/models/ModelEditor.tsx +1 -1
  59. package/components/models/ModelsPanel.tsx +34 -8
  60. package/components/models/model-editor/GitHubCopilotAuth.tsx +1 -1
  61. package/components/models/model-editor/save-helpers.ts +2 -2
  62. package/components/models/model-editor/useModelEditorForm.ts +1 -1
  63. package/components/models/model-editor/useModelSaveHandlers.ts +1 -1
  64. package/components/setup/wizard/StepCredentials.tsx +1 -1
  65. package/components/setup/wizard/StepModel.tsx +3 -8
  66. package/hooks/useModels.ts +17 -1
  67. package/lib/agents/effective-model.test.ts +45 -0
  68. package/lib/agents/effective-model.ts +11 -5
  69. package/lib/agents/llm.ts +2 -2
  70. package/lib/agents/run-thread.ts +10 -5
  71. package/lib/agents/warm-summary-background.test.ts +1 -2
  72. package/lib/agents/warm-summary-background.ts +4 -7
  73. package/lib/db/migrations.ts +10 -0
  74. package/lib/health/probes.test.ts +8 -11
  75. package/lib/health/probes.ts +5 -31
  76. package/lib/models/editor-payload.ts +1 -1
  77. package/lib/providers/github-copilot-auth.ts +2 -3
  78. package/lib/providers/github-copilot.test.ts +27 -0
  79. package/lib/providers/github-copilot.ts +21 -44
  80. package/lib/stores/integrations.test.ts +7 -0
  81. package/lib/stores/integrations.ts +4 -4
  82. package/lib/stores/model-config.test.ts +88 -0
  83. package/lib/stores/model-config.ts +90 -8
  84. package/package.json +1 -1
  85. /package/.next/standalone/.next/static/{e2NV1wk6mWg3SIeELXevg → NUrC9xax1sp1Q70nlFqS6}/_buildManifest.js +0 -0
  86. /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 { deleteModelConfig, getModelConfig, upsertModelConfig, withModelConfigMutation } from "@/lib/stores/model-config";
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 ?? false,
88
+ body.is_default,
68
89
  body.credential_id ?? null,
69
90
  );
70
- return NextResponse.json({ ...r, params: redactInlineParams(r.params), is_default: Boolean(r.is_default) });
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 ?? false,
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 NextResponse.json({ error: String(err) }, { status: 500 });
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 NextResponse.json({ error: String(err) }, { status: 500 });
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
- // We deliberately do NOT return the broad GitHub Models cross-publisher
166
- // registry here — that lists hundreds of models from every vendor, which is
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
- const apiKey = (params.api_key as string | undefined) ?? process.env.GITHUB_TOKEN;
197
- if (apiKey && !isLikelyGitHubPat(apiKey)) {
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. Works even on a PAT — those
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_") || token.startsWith("gho_");
255
+ return token.startsWith("ghp_") || token.startsWith("github_pat_");
245
256
  }
246
257
 
247
- async function exchangeCopilotSessionToken(pat: string): Promise<string> {
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 ${pat}`,
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 defaultModel = models.find((m) => m.is_default);
23
- const selectedModel = models.find((m) => m.name === form.modelConfigName) ?? defaultModel;
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
- {form.modelConfigName && models.find((m) => m.name === form.modelConfigName) && (
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
- await update(m.name, { provider: m.provider, model_id: m.model_id, params: m.params, is_default: true });
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="flex items-center gap-3 py-2.5 border-b border-border/60 group cursor-pointer hover:bg-surface-3/30 transition-colors">
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="text-sm font-medium text-fg">{m.name}</span>
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
- Unlocks full model context windows (vs. the 8k cap on raw PATs via GitHub Models).
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. They work for Claude, GPT, Gemini, Copilot, and other providers without depending on your shell environment."
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.update(m.name, {
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); }}
@@ -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` set, that wins; otherwise the workspace-wide default
8
- * model is used. Both client (BridgeEditor, AgentEditor capability hints)
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 defaultModel = models.find((m) => m.is_default) ?? models[0] ?? null;
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 models.find((m) => m.name === agent.model_config_name) ?? defaultModel;
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 { getModelConfig, getDefaultModelConfig, getModelParams, upsertModelConfig } from "@/lib/stores/model-config";
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 ? getModelConfig(cfgName) : null) ?? getDefaultModelConfig();
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" } };