@mycadet/assistant 0.12.10 → 0.12.11

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.
@@ -10,7 +10,7 @@ import {
10
10
  trimmedNonEmptyString,
11
11
  TUNNEL_PROVIDERS,
12
12
  type TunnelRecord,
13
- tunnelHostForPlatformHost,
13
+ tunnelHostForCloudHost,
14
14
  } from "../ingress.js";
15
15
  import {
16
16
  buildTwilioMediaStreamUrl,
@@ -19,27 +19,27 @@ import {
19
19
  resolveTwilioPublicBaseUrl,
20
20
  } from "../twilio-ingress.js";
21
21
 
22
- describe("tunnelHostForPlatformHost", () => {
22
+ describe("tunnelHostForCloudHost", () => {
23
23
  test("maps the prod platform host to the prod tunnel", () => {
24
- expect(tunnelHostForPlatformHost("cloud.mycadet.ai")).toBe(
24
+ expect(tunnelHostForCloudHost("cloud.mycadet.ai")).toBe(
25
25
  "tunnel.mycadet.ai",
26
26
  );
27
27
  });
28
28
 
29
29
  test("maps env-prefixed platform hosts to their env tunnel", () => {
30
- expect(tunnelHostForPlatformHost("staging-cloud.mycadet.ai")).toBe(
30
+ expect(tunnelHostForCloudHost("staging-cloud.mycadet.ai")).toBe(
31
31
  "tunnel-staging.mycadet.ai",
32
32
  );
33
- expect(tunnelHostForPlatformHost("dev-cloud.mycadet.ai")).toBe(
33
+ expect(tunnelHostForCloudHost("dev-cloud.mycadet.ai")).toBe(
34
34
  "tunnel-dev.mycadet.ai",
35
35
  );
36
36
  });
37
37
 
38
38
  test("returns null for hosts outside the deployment convention", () => {
39
- expect(tunnelHostForPlatformHost("localhost")).toBeNull();
40
- expect(tunnelHostForPlatformHost("platform.example.com")).toBeNull();
39
+ expect(tunnelHostForCloudHost("localhost")).toBeNull();
40
+ expect(tunnelHostForCloudHost("platform.example.com")).toBeNull();
41
41
  expect(
42
- tunnelHostForPlatformHost("staging-cloud.mycadet.ai.evil.com"),
42
+ tunnelHostForCloudHost("staging-cloud.mycadet.ai.evil.com"),
43
43
  ).toBeNull();
44
44
  });
45
45
  });
@@ -6,7 +6,7 @@
6
6
  * gateway speech relay and the web live-voice client so the two ends of the
7
7
  * cloud-speech transport can't drift.
8
8
  */
9
- export function tunnelHostForPlatformHost(host: string): string | null {
9
+ export function tunnelHostForCloudHost(host: string): string | null {
10
10
  if (host === "cloud.mycadet.ai") {
11
11
  return "tunnel.mycadet.ai";
12
12
  }
@@ -143,6 +143,19 @@ export interface CesHttpCredentialClient {
143
143
  // Factory
144
144
  // ---------------------------------------------------------------------------
145
145
 
146
+ function listErrorContext(
147
+ response: Response,
148
+ path: string,
149
+ ): Record<string, unknown> {
150
+ const requestId = response.headers.get("x-ces-error-id");
151
+ return {
152
+ status: response.status,
153
+ method: "GET",
154
+ path,
155
+ ...(requestId && /^[a-f0-9-]{36}$/i.test(requestId) ? { requestId } : {}),
156
+ };
157
+ }
158
+
146
159
  export function createCesHttpCredentialClient(
147
160
  config: CesHttpCredentialConfig,
148
161
  logger: CesHttpLogger,
@@ -298,7 +311,7 @@ export function createCesHttpCredentialClient(
298
311
  if (!res) return { accounts: [], unreachable: true };
299
312
  if (!res.ok) {
300
313
  logger.warn(
301
- { status: res.status },
314
+ listErrorContext(res, "/v1/credentials"),
302
315
  "CES credential list returned non-OK status",
303
316
  );
304
317
  return { accounts: [], unreachable: true };
@@ -399,6 +412,10 @@ export function createCesHttpCredentialClient(
399
412
  return { records: [], unreachable: true };
400
413
  }
401
414
  if (!res.ok) {
415
+ logger.warn(
416
+ listErrorContext(res, "/v1/metadata"),
417
+ "CES metadata list returned non-OK status",
418
+ );
402
419
  return { records: [], unreachable: true };
403
420
  }
404
421
  const data = (await res.json()) as {
@@ -10,7 +10,7 @@ import {
10
10
  trimmedNonEmptyString,
11
11
  TUNNEL_PROVIDERS,
12
12
  type TunnelRecord,
13
- tunnelHostForPlatformHost,
13
+ tunnelHostForCloudHost,
14
14
  } from "../ingress.js";
15
15
  import {
16
16
  buildTwilioMediaStreamUrl,
@@ -19,27 +19,27 @@ import {
19
19
  resolveTwilioPublicBaseUrl,
20
20
  } from "../twilio-ingress.js";
21
21
 
22
- describe("tunnelHostForPlatformHost", () => {
22
+ describe("tunnelHostForCloudHost", () => {
23
23
  test("maps the prod platform host to the prod tunnel", () => {
24
- expect(tunnelHostForPlatformHost("cloud.mycadet.ai")).toBe(
24
+ expect(tunnelHostForCloudHost("cloud.mycadet.ai")).toBe(
25
25
  "tunnel.mycadet.ai",
26
26
  );
27
27
  });
28
28
 
29
29
  test("maps env-prefixed platform hosts to their env tunnel", () => {
30
- expect(tunnelHostForPlatformHost("staging-cloud.mycadet.ai")).toBe(
30
+ expect(tunnelHostForCloudHost("staging-cloud.mycadet.ai")).toBe(
31
31
  "tunnel-staging.mycadet.ai",
32
32
  );
33
- expect(tunnelHostForPlatformHost("dev-cloud.mycadet.ai")).toBe(
33
+ expect(tunnelHostForCloudHost("dev-cloud.mycadet.ai")).toBe(
34
34
  "tunnel-dev.mycadet.ai",
35
35
  );
36
36
  });
37
37
 
38
38
  test("returns null for hosts outside the deployment convention", () => {
39
- expect(tunnelHostForPlatformHost("localhost")).toBeNull();
40
- expect(tunnelHostForPlatformHost("platform.example.com")).toBeNull();
39
+ expect(tunnelHostForCloudHost("localhost")).toBeNull();
40
+ expect(tunnelHostForCloudHost("platform.example.com")).toBeNull();
41
41
  expect(
42
- tunnelHostForPlatformHost("staging-cloud.mycadet.ai.evil.com"),
42
+ tunnelHostForCloudHost("staging-cloud.mycadet.ai.evil.com"),
43
43
  ).toBeNull();
44
44
  });
45
45
  });
@@ -6,7 +6,7 @@
6
6
  * gateway speech relay and the web live-voice client so the two ends of the
7
7
  * cloud-speech transport can't drift.
8
8
  */
9
- export function tunnelHostForPlatformHost(host: string): string | null {
9
+ export function tunnelHostForCloudHost(host: string): string | null {
10
10
  if (host === "cloud.mycadet.ai") {
11
11
  return "tunnel.mycadet.ai";
12
12
  }
@@ -10,7 +10,7 @@ import {
10
10
  trimmedNonEmptyString,
11
11
  TUNNEL_PROVIDERS,
12
12
  type TunnelRecord,
13
- tunnelHostForPlatformHost,
13
+ tunnelHostForCloudHost,
14
14
  } from "../ingress.js";
15
15
  import {
16
16
  buildTwilioMediaStreamUrl,
@@ -19,27 +19,27 @@ import {
19
19
  resolveTwilioPublicBaseUrl,
20
20
  } from "../twilio-ingress.js";
21
21
 
22
- describe("tunnelHostForPlatformHost", () => {
22
+ describe("tunnelHostForCloudHost", () => {
23
23
  test("maps the prod platform host to the prod tunnel", () => {
24
- expect(tunnelHostForPlatformHost("cloud.mycadet.ai")).toBe(
24
+ expect(tunnelHostForCloudHost("cloud.mycadet.ai")).toBe(
25
25
  "tunnel.mycadet.ai",
26
26
  );
27
27
  });
28
28
 
29
29
  test("maps env-prefixed platform hosts to their env tunnel", () => {
30
- expect(tunnelHostForPlatformHost("staging-cloud.mycadet.ai")).toBe(
30
+ expect(tunnelHostForCloudHost("staging-cloud.mycadet.ai")).toBe(
31
31
  "tunnel-staging.mycadet.ai",
32
32
  );
33
- expect(tunnelHostForPlatformHost("dev-cloud.mycadet.ai")).toBe(
33
+ expect(tunnelHostForCloudHost("dev-cloud.mycadet.ai")).toBe(
34
34
  "tunnel-dev.mycadet.ai",
35
35
  );
36
36
  });
37
37
 
38
38
  test("returns null for hosts outside the deployment convention", () => {
39
- expect(tunnelHostForPlatformHost("localhost")).toBeNull();
40
- expect(tunnelHostForPlatformHost("platform.example.com")).toBeNull();
39
+ expect(tunnelHostForCloudHost("localhost")).toBeNull();
40
+ expect(tunnelHostForCloudHost("platform.example.com")).toBeNull();
41
41
  expect(
42
- tunnelHostForPlatformHost("staging-cloud.mycadet.ai.evil.com"),
42
+ tunnelHostForCloudHost("staging-cloud.mycadet.ai.evil.com"),
43
43
  ).toBeNull();
44
44
  });
45
45
  });
@@ -6,7 +6,7 @@
6
6
  * gateway speech relay and the web live-voice client so the two ends of the
7
7
  * cloud-speech transport can't drift.
8
8
  */
9
- export function tunnelHostForPlatformHost(host: string): string | null {
9
+ export function tunnelHostForCloudHost(host: string): string | null {
10
10
  if (host === "cloud.mycadet.ai") {
11
11
  return "tunnel.mycadet.ai";
12
12
  }
package/openapi.yaml CHANGED
@@ -3,7 +3,7 @@
3
3
  openapi: 3.1.0
4
4
  info:
5
5
  title: Cadet Assistant API
6
- version: 0.12.10
6
+ version: 0.12.11
7
7
  description: Auto-generated OpenAPI specification for the Cadet Assistant runtime HTTP server.
8
8
  servers:
9
9
  - url: http://127.0.0.1:7821
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mycadet/assistant",
3
- "version": "0.12.10",
3
+ "version": "0.12.11",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -138,6 +138,42 @@ function savedProfile(name: string): Record<string, unknown> {
138
138
  >;
139
139
  }
140
140
 
141
+ describe("partial default-provider config", () => {
142
+ for (const provider of ["cadet", "chatgpt"]) {
143
+ test(`timezone PATCH accepts a model-less ${provider} default`, async () => {
144
+ seedRawConfig({ llm: { default: { provider } } });
145
+ await patchRoute.handler({
146
+ body: { ui: { userTimezone: "America/New_York" } },
147
+ });
148
+ const saved = committedRaw() as {
149
+ ui: { userTimezone: string };
150
+ llm: { default: { provider: string; model?: string } };
151
+ };
152
+ expect(saved.ui.userTimezone).toBe("America/New_York");
153
+ expect(saved.llm.default).toEqual({ provider });
154
+ expectOneCommitCycle();
155
+ });
156
+ test(`PATCH can select ${provider} as default without a model`, async () => {
157
+ seedRawConfig({});
158
+ await patchRoute.handler({ body: { llm: { default: { provider } } } });
159
+ expect(
160
+ (committedRaw() as { llm: { default: { provider: string } } }).llm
161
+ .default,
162
+ ).toEqual({ provider });
163
+ });
164
+ }
165
+ test("Zod rejects a default model with the wrong type", async () => {
166
+ await expect(
167
+ patchRoute.handler({
168
+ body: {
169
+ llm: { default: { provider: "cadet", model: 42 } },
170
+ },
171
+ }),
172
+ ).rejects.toThrow(/model|expected string/i);
173
+ expectNothingCommitted();
174
+ });
175
+ });
176
+
141
177
  // ---------------------------------------------------------------------------
142
178
  // PUT /v1/config/llm/profiles/:name — replace inference profile
143
179
  // ---------------------------------------------------------------------------
@@ -210,13 +246,13 @@ describe("PUT /v1/config/llm/profiles/:name — cloud profile guard", () => {
210
246
  ).toBeUndefined();
211
247
  });
212
248
 
213
- test("rejects a routing identity with an unroutable model at the write choke point", async () => {
249
+ test("Zod rejects invalid fields in a replacement profile", async () => {
214
250
  await expect(
215
251
  replaceRoute.handler({
216
252
  pathParams: { name: "my-custom" },
217
- body: { provider: "cadet", model: "not-a-real-model" },
253
+ body: { provider: "cadet", model: "claude-opus-4-8", maxTokens: 0 },
218
254
  }),
219
- ).rejects.toThrow(/not served by the Cadet Cloud route/);
255
+ ).rejects.toThrow(/expected number to be >0/);
220
256
  expect(committedRaw()).toBeNull();
221
257
  });
222
258
 
@@ -54,9 +54,8 @@ const ALWAYS_RESOLVING_MANAGED_ONLY_PROFILE_KEYS =
54
54
  * an open string so a stored provider outside this list parses instead of
55
55
  * stripping its profile; dispatch resolves such a label as a connection
56
56
  * entry name and fails with an explainable resolution error when no row
57
- * carries it. Write-time membership is enforced at the profiles route and
58
- * the `commitConfigWrite` choke point (`unknownLlmProviderIssue`), which
59
- * is where entry names unlock when the entries model enables them.
57
+ * carries it. The generic config routes follow this open-string schema;
58
+ * dedicated profile routes apply their own provider selection rules.
60
59
  */
61
60
  export const KNOWN_LLM_PROVIDERS = [
62
61
  "anthropic",
@@ -153,8 +152,7 @@ export function isByokDefaultProviderChoice(
153
152
  * on that profile/call site deterministically — a call-site fragment naming
154
153
  * an identity without a model would inherit whatever model the winning
155
154
  * profile carries, which the identity may not serve. Enforced at parse time
156
- * (LLMSchema.superRefine), at the config write choke point
157
- * (commitConfigWrite), and by the profile write route.
155
+ * (LLMSchema.superRefine) and by the profile creation route.
158
156
  */
159
157
  export function routingIdentityModelIssue(
160
158
  provider: string,
@@ -1625,7 +1625,7 @@ describe("config GET serves profiles verbatim", () => {
1625
1625
  });
1626
1626
  });
1627
1627
 
1628
- describe("provider membership at the write choke point", () => {
1628
+ describe("provider labels follow the open-string Zod schema", () => {
1629
1629
  const configPatchRoute = ROUTES.find(
1630
1630
  (r) => r.operationId === "config_patch",
1631
1631
  )!;
@@ -1659,21 +1659,23 @@ describe("provider membership at the write choke point", () => {
1659
1659
  expect(llm.profiles?.work?.provider).toBe("anthropic-work");
1660
1660
  });
1661
1661
 
1662
- test("introducing an unknown provider is rejected", async () => {
1662
+ test("a new provider label is accepted by the schema", async () => {
1663
1663
  rawConfigFixture = { llm: {} };
1664
1664
  seedRawConfig();
1665
-
1666
- await expect(
1667
- configPatchRoute.handler({
1668
- body: {
1669
- llm: {
1670
- profiles: {
1671
- rogue: { source: "user", provider: "not-a-provider", model: "m" },
1665
+ await configPatchRoute.handler({
1666
+ body: {
1667
+ llm: {
1668
+ profiles: {
1669
+ work: {
1670
+ source: "user",
1671
+ provider: "anthropic-work",
1672
+ model: "claude-opus-4-8",
1672
1673
  },
1673
1674
  },
1674
1675
  },
1675
- }),
1676
- ).rejects.toThrow(/Invalid provider/);
1676
+ },
1677
+ });
1678
+ expect(persistedProfile("work").provider).toBe("anthropic-work");
1677
1679
  });
1678
1680
  });
1679
1681
 
@@ -53,8 +53,6 @@ import {
53
53
  LLMDefaultConfigSchema,
54
54
  LLMSchema,
55
55
  ProfileEntry,
56
- routingIdentityModelIssue,
57
- unknownLlmProviderIssue,
58
56
  } from "../../config/schemas/llm.js";
59
57
  import { VALID_MEMORY_EMBEDDING_PROVIDERS } from "../../config/schemas/memory-storage.js";
60
58
  import { ServiceModeSchema } from "../../config/schemas/services.js";
@@ -106,7 +104,6 @@ import { getMemoryRecallLogByMessageIds } from "../../plugins/defaults/memory/me
106
104
  import { getMemoryV2ActivationLogByMessageIds } from "../../plugins/defaults/memory/v2/activation-log-store.js";
107
105
  import { getMemoryV3SelectionForInspectorByMessageIds } from "../../plugins/defaults/memory/v3/selection-log-store.js";
108
106
  import { CLOUD_ROUTABLE_PROVIDERS } from "../../providers/cadet-model-routing.js";
109
- import { writableProfileProviderIssue } from "../../providers/connection-resolution.js";
110
107
  import { ROUTING_IDENTITY_PROVIDERS } from "../../providers/inference/auth.js";
111
108
  import { PROVIDERS_REQUIRING_BASE_URL_AND_MODELS } from "../../providers/inference/auth.js";
112
109
  import {
@@ -684,98 +681,6 @@ function handleGetConfigSchema({ queryParams = {} }: RouteHandlerArgs) {
684
681
  };
685
682
  }
686
683
 
687
- /**
688
- * Reject writes that would store a routing-identity provider with a missing
689
- * or unroutable model (see `routingIdentityModelIssue`). saveRawConfig
690
- * persists without schema validation, so the parse-time rejection alone
691
- * would let the pair reach disk and be stripped on the next read — a silent
692
- * no-op where the user expects a saved override.
693
- */
694
- function collectProviderEntries(
695
- raw: Record<string, unknown>,
696
- ): [string, { provider?: unknown; model?: unknown }][] {
697
- const llm = raw.llm as
698
- | {
699
- default?: { provider?: unknown; model?: unknown } | null;
700
- profiles?: Record<
701
- string,
702
- { provider?: unknown; model?: unknown } | null | undefined
703
- >;
704
- callSites?: Record<
705
- string,
706
- { provider?: unknown; model?: unknown } | null | undefined
707
- >;
708
- }
709
- | undefined;
710
- const entries: [string, { provider?: unknown; model?: unknown }][] = [];
711
- // llm.default is a raw compatibility field outside the parsed schema;
712
- // profile materialization uses the on-disk blob as its fill base, so it is
713
- // checked like the schema-carried sections.
714
- if (llm?.default) {
715
- entries.push(["llm.default", llm.default]);
716
- }
717
- for (const section of ["profiles", "callSites"] as const) {
718
- for (const [name, entry] of Object.entries(llm?.[section] ?? {})) {
719
- if (entry) {
720
- entries.push([`llm.${section}.${name}`, entry]);
721
- }
722
- }
723
- }
724
- return entries;
725
- }
726
-
727
- function assertRoutableIdentityEntries(
728
- preWrite: Record<string, unknown>,
729
- raw: Record<string, unknown>,
730
- ): void {
731
- const entries = collectProviderEntries(raw);
732
- const priorProviders = new Map(
733
- collectProviderEntries(preWrite).flatMap(([label, entry]) =>
734
- typeof entry.provider === "string" ? [[label, entry.provider]] : [],
735
- ),
736
- );
737
- for (const [label, entry] of entries) {
738
- if (typeof entry.provider !== "string") {
739
- continue;
740
- }
741
- // The provider schema is an open string (a stored value outside the
742
- // known set parses instead of stripping its profile), so write-time
743
- // membership is enforced here and at the profiles route. Scoped to
744
- // providers this write introduces or changes: a stored value outside
745
- // the known set is readable and dispatchable by design, and
746
- // re-validating it on every write would make all later settings saves
747
- // fail with no in-product repair path. Profiles accept entry names;
748
- // call-site fragments and the legacy default blob stay vendor-only
749
- // (overrides become model-only with the entries demolition).
750
- if (entry.provider !== priorProviders.get(label)) {
751
- const providerIssue = label.startsWith("llm.profiles.")
752
- ? writableProfileProviderIssue(entry.provider)
753
- : unknownLlmProviderIssue(entry.provider);
754
- if (providerIssue) {
755
- throw new BadRequestError(`${providerIssue} (${label})`);
756
- }
757
- }
758
- // A model-less entry under a code-defined default name is the thin
759
- // managed stub — its content is code-owned, so the routing-identity
760
- // model check applies only to entries carrying their own body.
761
- const profileName = label.startsWith("llm.profiles.")
762
- ? label.slice("llm.profiles.".length)
763
- : undefined;
764
- const isManagedName =
765
- profileName != null && MANAGED_PROFILE_NAMES.has(profileName);
766
- if (entry.model == null && isManagedName) {
767
- continue;
768
- }
769
- const issue = routingIdentityModelIssue(
770
- entry.provider,
771
- typeof entry.model === "string" ? entry.model : undefined,
772
- );
773
- if (issue) {
774
- throw new BadRequestError(`${issue} (${label})`);
775
- }
776
- }
777
- }
778
-
779
684
  export async function commitConfigWrite(
780
685
  raw: Record<string, unknown>,
781
686
  opLabel: string,
@@ -792,7 +697,6 @@ export async function commitConfigWrite(
792
697
  if (!chatSettings.success) {
793
698
  throw new BadRequestError(chatSettings.error.message);
794
699
  }
795
- assertRoutableIdentityEntries(preWrite, raw);
796
700
 
797
701
  // Suppress the file-watcher callback for the duration of the debounce
798
702
  // window. Without this, the ConfigWatcher detects the config.json write