Boot a coder session against a dev server, and ask it what it serves

9d86be19e76a · AtlantisPleb · · parent 89ec3c1125ce

Boot a coder session against a dev server, and ask it what it serves

A deploy can take half an hour. Iterating against a server nobody has deployed
to yet is the difference between a change being testable now and after lunch, so
`coder --dev` points a session at `http://localhost:4000`. It checks the server
is answering first and says how to start one when it is not, because a session
that opens against nothing fails later and less clearly.

Pointing a session at a dev server immediately found the reason to have done it.
The client hardcoded its backend list and led with `gemini-3.7-flash`. The dev
server's catalog is `gpt-5.6-luna` and `ox-alpha`, and no deployment has ever
served a model by the id the client was naming, so every session that took the
default opened its thread against a catalog that refused it — a bare
`422 Validation Failed` with no model named and no alternative offered.

The list's own docstring said the server owns it. It does, and now the client
reads it: `GET /api/v3/models`, before the thread is opened. That answers a
question the static list could not ask at all — whether a model is *available*
here, its provider credential configured — which is the difference between a
thread that answers and one that fails at its first call.

`gemini-3.7-flash` stays as the preference. It is a preference now rather than
an answer: where the server serves it, a session leads with it; where it does
not, the session falls to the server's own default rather than to a name the
catalog will refuse. A model named with `--model` is checked against the same
catalog, so the refusal can say which model, why, and what this deployment does
serve.

The static list stays for `--offline` and for a session with no credential,
where there is nothing to ask. It never overrules a server that has spoken —
which also fixes `--model gpt-5.6-luna` being rejected by the client for a model
the server serves.

Every login hint now names the profile it is talking about. A dev session told
to run `openagents auth login` was sent around a loop that signed it into
production and left the dev server still unauthenticated.

Verified against a dev server: a session opens, falls to `gpt-5.6-luna` because
that is what is configured there, and answers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TRDRrfL1khQhQtNr3SRrA
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified packages/openagents-cli/src/cli.ts
  • modified packages/openagents-cli/src/coder-backends.ts
  • modified packages/openagents-cli/test/coder-backends.test.ts

Diff

3 files changed, +392 -39

packages/openagents-cli/src/cli.ts modified +114 -17

@@ -37,7 +37,13 @@ import type { ReplySource } from "./coder-session.js";

37 37
import { CoderSession, DummyReplySource } from "./coder-session.js";
38 38
import { CoderTaskRegistry } from "./coder-tasks.js";
39 39
import { runCoderUi } from "./coder-ui.js";
40
import { backendIds, defaultBackendId } from "./coder-backends.js";
40
import {
41
  backendIds,
42
  chooseBackend,
43
  defaultBackendId,
44
  fetchServedCatalog,
45
  refuseBackend,
46
} from "./coder-backends.js";
41 47
import {
42 48
  discoverOllamaModel,
43 49
  isOllamaModelFlag,

@@ -710,6 +716,23 @@ const loginResumeFlag = Flag.boolean("resume").pipe(

710 716
  Flag.withDescription("Complete the pending device authorization for the selected API"),
711 717
);
712 718
719
/**
720
 * The login that reaches this session's server. Credentials are stored per
721
 * endpoint, so a session pointed anywhere other than production is not served
722
 * by a bare `openagents auth login` — the same profile has to be named again,
723
 * and a hint that omits it sends the reader around a loop that never signs
724
 * them in.
725
 */
726
const loginCommandFor = (endpoint: {
727
  readonly origin: string;
728
  readonly profile: string;
729
}): string =>
730
  endpoint.profile === "production"
731
    ? "openagents auth login"
732
    : endpoint.profile === "custom"
733
      ? `openagents --api-url ${endpoint.origin} auth login`
734
      : `openagents --profile ${endpoint.profile} auth login`;
735
713 736
const resumeCommandFor = (endpoint: {
714 737
  readonly origin: string;
715 738
  readonly profile: string;

@@ -1475,6 +1498,11 @@ const coderPlainFlag = Flag.boolean("plain").pipe(

1475 1498
const coderOfflineFlag = Flag.boolean("offline").pipe(
1476 1499
  Flag.withDescription("Answer from the built-in stand-in instead of the chat API"),
1477 1500
);
1501
const coderDevFlag = Flag.boolean("dev").pipe(
1502
  Flag.withDescription(
1503
    "Talk to a development server on this machine instead of the production API",
1504
  ),
1505
);
1478 1506
const coderLocalFlag = Flag.boolean("local").pipe(
1479 1507
  Flag.withDescription(
1480 1508
    "Answer from a model running on this machine through Ollama, instead of the coder backend",

@@ -1778,6 +1806,7 @@ const coderCommand = Command.make(

1778 1806
    plain: coderPlainFlag,
1779 1807
    offline: coderOfflineFlag,
1780 1808
    local: coderLocalFlag,
1809
    dev: coderDevFlag,
1781 1810
    resume: coderResumeFlag,
1782 1811
    last: coderLastFlag,
1783 1812
    all: coderAllFlag,

@@ -1794,6 +1823,7 @@ const coderCommand = Command.make(

1794 1823
    plain,
1795 1824
    offline,
1796 1825
    local,
1826
    dev,
1797 1827
    resume,
1798 1828
    last,
1799 1829
    all,

@@ -1809,7 +1839,36 @@ const coderCommand = Command.make(

1809 1839
      const flags = yield* rootCommand;
1810 1840
      const terminal = yield* TerminalSession;
1811 1841
      const workspace = describeWorkspace();
1812
      const endpoint = yield* resolveApiEndpoint(endpointOverrides(flags));
1842
      // `--dev` is the `local` profile by another name, so one flag points a
1843
      // session at a server on this machine. A deploy can take half an hour,
1844
      // and iterating against a server nobody has deployed to yet is the
1845
      // difference between a change being testable now and after lunch.
1846
      const endpoint = yield* resolveApiEndpoint(
1847
        dev
1848
          ? { profile: Option.some("local" as Profile), apiUrl: Option.none() }
1849
          : endpointOverrides(flags),
1850
      );
1851
1852
      if (dev) {
1853
        const reachable = yield* Effect.promise(async () => {
1854
          try {
1855
            const answer = await fetch(new URL("/healthz", endpoint.origin), {
1856
              signal: AbortSignal.timeout(1_500),
1857
            });
1858
            return answer.ok;
1859
          } catch {
1860
            return false;
1861
          }
1862
        });
1863
1864
        if (!reachable) {
1865
          return yield* new InputError({
1866
            message:
1867
              `No development server is answering at ${endpoint.origin}. ` +
1868
              "Start it with `mix phx.server` in the openagents.com checkout, or drop --dev.",
1869
          });
1870
        }
1871
      }
1813 1872
1814 1873
      // A `--model` value that starts with `ollama:` goes to the local Ollama
1815 1874
      // server and needs no account credential.

@@ -1891,16 +1950,6 @@ const coderCommand = Command.make(

1891 1950
1892 1951
      const ollamaName = resolved?.model ?? askedFor;
1893 1952
1894
      // Any other `--model` value still has to name a published backend. The
1895
      // flag takes a string so an `ollama:` prefix can reach the local server,
1896
      // which costs the enum `Flag.choice` used to enforce, so the check moves
1897
      // here rather than disappearing.
1898
      if (named !== undefined && !wantsOllama && !backendIds().includes(named)) {
1899
        return yield* new InputError({
1900
          message: `Unknown model ${named}. Use ollama:<model> for a local Ollama server, or one of: ${backendIds().join(", ")}.`,
1901
        });
1902
      }
1903
1904 1953
      // The session opens a thread of its own and spends that thread's grant,
1905 1954
      // so the CLI still holds no provider key and nothing typed here reaches
1906 1955
      // the account's conversation. Without a credential it falls back to the

@@ -1927,7 +1976,7 @@ const coderCommand = Command.make(

1927 1976
              if (Option.isNone(stored)) {
1928 1977
                throw new ThreadUnavailable(
1929 1978
                  "scope_missing",
1930
                  "Resuming reads the account's threads. Run `openagents auth login` first.",
1979
                  `Resuming reads the account's threads. Run \`${loginCommandFor(endpoint)}\` first.`,
1931 1980
                );
1932 1981
              }
1933 1982
              const api = { origin: endpoint.origin, token: Redacted.value(stored.value.token) };

@@ -1984,6 +2033,52 @@ const coderCommand = Command.make(

1984 2033
1985 2034
      if (resume && resumed === undefined) return;
1986 2035
2036
      // What this deployment actually serves. Asked once, before a thread is
2037
      // opened, because the model a session leads with is a preference and the
2038
      // catalog is the only thing that knows whether it can be honoured here.
2039
      // A dev server and production do not serve the same list, and a client
2040
      // that assumes one of them is wrong against the other.
2041
      const served =
2042
        Option.isSome(stored) && !wantsOllama && !offline && resumed === undefined
2043
          ? yield* Effect.promise(() =>
2044
              fetchServedCatalog({
2045
                origin: endpoint.origin,
2046
                token: Redacted.value(stored.value.token),
2047
              }),
2048
            )
2049
          : undefined;
2050
2051
      // A named model the server cannot serve is refused here, where the reason
2052
      // can name the model and list the alternatives, rather than at the thread
2053
      // route, where it is a bare `Validation Failed`.
2054
      //
2055
      // Where the catalog could not be read, the static list stands in. It is
2056
      // the weaker check — it cannot know what a given deployment has a
2057
      // credential for — so it runs only when there is nothing better, and it
2058
      // never overrules a server that has spoken. The flag takes a string
2059
      // rather than an enum so an `ollama:` prefix can reach the local server,
2060
      // which is why this is a check and not `Flag.choice`.
2061
      if (named !== undefined && !wantsOllama) {
2062
        const refusal =
2063
          served !== undefined
2064
            ? refuseBackend(served, named)
2065
            : backendIds().includes(named)
2066
              ? undefined
2067
              : `Unknown model ${named}. Use ollama:<model> for a local Ollama server, ` +
2068
                `or one of: ${backendIds().join(", ")}.`;
2069
        if (refusal !== undefined) return yield* new InputError({ message: refusal });
2070
      }
2071
2072
      const chosen = served === undefined ? undefined : chooseBackend(served, named);
2073
2074
      if (served !== undefined && chosen === undefined) {
2075
        return yield* new InputError({
2076
          message:
2077
            `No model on ${endpoint.origin} has a configured provider credential, ` +
2078
            "so a session opened there could not answer.",
2079
        });
2080
      }
2081
1987 2082
      const thread =
1988 2083
        resumed !== undefined
1989 2084
          ? resumed.source

@@ -1998,8 +2093,10 @@ const coderCommand = Command.make(

1998 2093
                    // records it on the thread, and `--resume` filters on it
1999 2094
                    // rather than parsing the objective back.
2000 2095
                    repository: workspace.repository,
2001
                    // The named backend, or the one this build leads with.
2002
                    model: named ?? defaultBackendId(),
2096
                    // What the server said it serves, having been asked. The
2097
                    // static fallback is for a server too old to publish a
2098
                    // catalog, which is the only case where `chosen` is absent.
2099
                    model: chosen?.id ?? named ?? defaultBackendId(),
2003 2100
                    reasoning: Option.getOrUndefined(reasoning),
2004 2101
                  }),
2005 2102
                // The server's own code and sentence, which is what turns a ninth

@@ -2174,7 +2271,7 @@ const coderCommand = Command.make(

2174 2271
      if (Option.isNone(stored) && !offline && !wantsOllama) {
2175 2272
        session.notice(
2176 2273
          "No stored credential, so replies come from the built-in stand-in. " +
2177
            "Run `openagents auth login` to reach a real model.",
2274
            `Run \`${loginCommandFor(endpoint)}\` to reach a real model.`,
2178 2275
        );
2179 2276
      }
2180 2277

@@ -2364,7 +2461,7 @@ const delegateCommand = Command.make(

2364 2461
      if (setup === undefined) {
2365 2462
        return yield* new InputError({
2366 2463
          message:
2367
            "Nothing to run children on. Sign in with `openagents auth login` so " +
2464
            `Nothing to run children on. Sign in with \`${loginCommandFor(endpoint)}\` so ` +
2368 2465
            "children can spend a thread, or pass --child-model provider/model to run " +
2369 2466
            "them on a provider of your own.",
2370 2467
        });
packages/openagents-cli/src/coder-backends.ts modified +126 -21

@@ -1,23 +1,27 @@

1 1
/**
2 2
 * The backends `openagents coder` can send a turn to.
3 3
 *
4
 * The server owns the real list and publishes it at `GET /api/v3` under
5
 * `extensions["chat.openagents"].parameters.model`. This is the client's copy,
6
 * kept as data for the same reason the server keeps one: a backend the status
7
 * line shows and the `--model` flag accepts has to be one list, or the two
8
 * drift and the CLI offers something the server refuses.
4
 * The server owns the real list and publishes it at `GET /api/v3/models`, and a
5
 * session that can reach the server reads it from there — see
6
 * `fetchServedCatalog` below. The list in this file is the fallback for a
7
 * session that cannot: `--offline`, or no stored credential.
8
 *
9
 * A hardcoded copy is a copy that drifts. This one did: it named
10
 * `gemini-3.7-flash`, no deployment had ever served a model by that id, and a
11
 * session that took the name from here opened its thread against a catalog that
12
 * refused it. The published catalog also says which models are *available* —
13
 * served here, credential configured — which a static list cannot say at all,
14
 * and which is the difference between a thread that answers and a 422.
9 15
 *
10 16
 * Choosing between them is not the client's call today. A coder session runs on
11 17
 * a thread, and the inference proxy takes the model from that thread's grant,
12 18
 * so this list is what the flag validates against and what the status line
13 19
 * names, nothing more.
14 20
 *
15
 * Adding a backend is one entry here and one entry on the server. Nothing else
16
 * in this package names a backend.
17
 *
18
 * The `id` is what `POST /api/v3/chat/turns` takes as `model`, so it must match
19
 * the server's published enum exactly. The `label` is what a person reads in
20
 * the status bar, where the whole line is competing for a narrow terminal.
21
 * The `id` is what `POST /api/v3/threads` takes as `model`. The `label` is what
22
 * a person reads in the status bar, where the whole line is competing for a
23
 * narrow terminal; a model the server serves and this file has never heard of
24
 * is labelled with its own id.
21 25
 */
22 26
23 27
export interface CoderBackend {

@@ -33,17 +37,12 @@ export const CODER_BACKENDS: readonly CoderBackend[] = [

33 37
];
34 38
35 39
/**
36
 * What a coder session opens on when nobody names a backend.
37
 *
38
 * Deliberately not the server's own default, which is the catalog's first entry
39
 * and serves every caller of the chat API. A coder turn is a long one with tools
40
 * in it, and this build leads with the fast model for that; a reader who wants
41
 * the other says so with `--model`.
40
 * The backend a coder session leads with when nobody names one.
42 41
 *
43
 * Named rather than taken from the list's order, because the order here mirrors
44
 * the server's published enum and a test holds the two together. Expressing a
45
 * preference by reordering would have broken that agreement to say something the
46
 * list was never saying.
42
 * A *preference*, not an answer. A coder turn is a long one with tools in it,
43
 * and this build leads with the fast model for that — but only where the server
44
 * actually serves it. Where it does not, `chooseBackend` falls to the server's
45
 * own default rather than opening a thread on a name the catalog will refuse.
47 46
 */
48 47
export const DEFAULT_CODER_BACKEND = "gemini-3.7-flash";
49 48

@@ -54,3 +53,109 @@ export const defaultBackendId = (): string =>

54 53
55 54
/** Every id, for a flag's error message and its accepted values. */
56 55
export const backendIds = (): readonly string[] => CODER_BACKENDS.map((backend) => backend.id);
56
57
/** One model as the server publishes it at `GET /api/v3/models`. */
58
export interface ServedModel {
59
  readonly id: string;
60
  /** Served here *and* its provider credential configured. */
61
  readonly available: boolean;
62
  /** The model the server itself falls back to. */
63
  readonly isDefault: boolean;
64
}
65
66
/**
67
 * What this deployment serves, read from the server rather than assumed.
68
 *
69
 * `undefined` means the question could not be asked — an older server without
70
 * the route, an unreachable one, a token that cannot read it. That is not the
71
 * same as "serves nothing", so the caller falls back to the static list rather
72
 * than refusing to start.
73
 */
74
export const fetchServedCatalog = async (
75
  api: { readonly origin: string; readonly token: string },
76
  signal?: AbortSignal,
77
): Promise<readonly ServedModel[] | undefined> => {
78
  try {
79
    const response = await fetch(new URL("/api/v3/models", api.origin), {
80
      headers: { authorization: `Bearer ${api.token}`, accept: "application/json" },
81
      signal: signal ?? AbortSignal.timeout(5_000),
82
    });
83
    if (!response.ok) return undefined;
84
85
    const body = (await response.json()) as {
86
      readonly models?: readonly {
87
        readonly id?: unknown;
88
        readonly availability?: unknown;
89
        readonly default?: unknown;
90
      }[];
91
    };
92
    if (!Array.isArray(body.models)) return undefined;
93
94
    const served = body.models.flatMap((model) =>
95
      typeof model.id === "string" && model.id.length > 0
96
        ? [
97
            {
98
              id: model.id,
99
              // Anything other than the word `available` is treated as not
100
              // available: a vocabulary this client has not seen is a reason to
101
              // pick a different model, not to assume the new word is benign.
102
              available: model.availability === "available",
103
              isDefault: model.default === true,
104
            },
105
          ]
106
        : [],
107
    );
108
    return served.length === 0 ? undefined : served;
109
  } catch {
110
    return undefined;
111
  }
112
};
113
114
/**
115
 * The backend to open a thread on, given what the server actually serves.
116
 *
117
 * The preference wins where it is served and available. Otherwise the server's
118
 * own default, then whatever else is available — because a session that can run
119
 * on something should run, and a reader who wanted the other model says so with
120
 * `--model` and gets told plainly when it cannot be had.
121
 *
122
 * `undefined` means the catalog is real and nothing in it can answer. That is a
123
 * server with no provider credential configured, and it is worth saying rather
124
 * than opening a thread that will fail at its first turn.
125
 */
126
export const chooseBackend = (
127
  served: readonly ServedModel[],
128
  preferred: string = DEFAULT_CODER_BACKEND,
129
): ServedModel | undefined =>
130
  served.find((model) => model.id === preferred && model.available) ??
131
  served.find((model) => model.isDefault && model.available) ??
132
  served.find((model) => model.available);
133
134
/**
135
 * Why this named model cannot be opened, or `undefined` if it can.
136
 *
137
 * Refusing here turns the server's `422 Validation Failed` — which names no
138
 * model and suggests no alternative — into a sentence that says which model,
139
 * why, and what this deployment does serve.
140
 */
141
export const refuseBackend = (
142
  served: readonly ServedModel[],
143
  named: string,
144
): string | undefined => {
145
  const match = served.find((model) => model.id === named);
146
  const usable = served.filter((model) => model.available).map((model) => model.id);
147
  const alternatives =
148
    usable.length === 0
149
      ? "This server has no model with a configured credential."
150
      : `This server serves ${usable.join(", ")}.`;
151
152
  if (match === undefined) return `No model called '${named}' is served here. ${alternatives}`;
153
  if (!match.available) {
154
    return `'${named}' is served here but its provider credential is not configured. ${alternatives}`;
155
  }
156
  return undefined;
157
};
158
159
/** The status-line name for a model id, which may be one the static list lacks. */
160
export const backendLabel = (id: string): string =>
161
  CODER_BACKENDS.find((backend) => backend.id === id)?.label ?? id;
packages/openagents-cli/test/coder-backends.test.ts modified +152 -1

@@ -1,6 +1,12 @@

1 1
import { describe, expect, it } from "vitest";
2 2
3
import { backendIds, CODER_BACKENDS } from "../src/coder-backends.js";
3
import {
4
  backendIds,
5
  chooseBackend,
6
  CODER_BACKENDS,
7
  fetchServedCatalog,
8
  refuseBackend,
9
} from "../src/coder-backends.js";
4 10
5 11
/**
6 12
 * The list two surfaces read.

@@ -28,3 +34,148 @@ describe("coder backends", () => {

28 34
    expect(backendIds()).toEqual(["ox-alpha", "gemini-3.7-flash"]);
29 35
  });
30 36
});
37
38
describe("choosing a backend from what the server serves", () => {
39
  const model = (id: string, available: boolean, isDefault = false) => ({
40
    id,
41
    available,
42
    isDefault,
43
  });
44
45
  it("leads with the preferred backend where the server serves it", () => {
46
    const chosen = chooseBackend([
47
      model("gpt-5.6-luna", true, true),
48
      model("gemini-3.7-flash", true),
49
    ]);
50
    expect(chosen?.id).toBe("gemini-3.7-flash");
51
  });
52
53
  it("falls to the server's own default when the preference is not served", () => {
54
    // The case that sent every session into a 422: no deployment served a model
55
    // by that id, and the client named it anyway.
56
    const chosen = chooseBackend([model("gpt-5.6-luna", true, true), model("ox-alpha", false)]);
57
    expect(chosen?.id).toBe("gpt-5.6-luna");
58
  });
59
60
  it("falls past an unavailable default to something that can answer", () => {
61
    const chosen = chooseBackend([model("gpt-5.6-luna", false, true), model("ox-alpha", true)]);
62
    expect(chosen?.id).toBe("ox-alpha");
63
  });
64
65
  it("honours an explicitly named model over the preference", () => {
66
    const chosen = chooseBackend(
67
      [model("gpt-5.6-luna", true, true), model("gemini-3.7-flash", true)],
68
      "gpt-5.6-luna",
69
    );
70
    expect(chosen?.id).toBe("gpt-5.6-luna");
71
  });
72
73
  it("chooses nothing when no model has a configured credential", () => {
74
    expect(chooseBackend([model("gpt-5.6-luna", false, true), model("ox-alpha", false)])).toBe(
75
      undefined,
76
    );
77
  });
78
});
79
80
describe("refusing a named backend", () => {
81
  const served = [
82
    { id: "gpt-5.6-luna", available: true, isDefault: true },
83
    { id: "ox-alpha", available: false, isDefault: false },
84
  ];
85
86
  it("says a model is not served here and names what is", () => {
87
    const refusal = refuseBackend(served, "gemini-3.7-flash");
88
    expect(refusal).toContain("gemini-3.7-flash");
89
    expect(refusal).toContain("gpt-5.6-luna");
90
  });
91
92
  it("separates a missing credential from a missing model", () => {
93
    const refusal = refuseBackend(served, "ox-alpha");
94
    expect(refusal).toContain("credential is not configured");
95
  });
96
97
  it("does not refuse a model the server serves and can run", () => {
98
    expect(refuseBackend(served, "gpt-5.6-luna")).toBe(undefined);
99
  });
100
101
  it("says so plainly when the deployment can run nothing", () => {
102
    const refusal = refuseBackend([{ id: "ox-alpha", available: false, isDefault: true }], "ox-alpha");
103
    expect(refusal).toContain("no model with a configured credential");
104
  });
105
});
106
107
describe("reading the published catalog", () => {
108
  const withFetch = async (
109
    handler: (url: string) => Response | Promise<Response>,
110
    run: () => Promise<unknown>,
111
  ) => {
112
    const original = globalThis.fetch;
113
    globalThis.fetch = ((input: URL | RequestInfo) =>
114
      Promise.resolve(handler(String(input)))) as typeof fetch;
115
    try {
116
      return await run();
117
    } finally {
118
      globalThis.fetch = original;
119
    }
120
  };
121
122
  const api = { origin: "http://localhost:4000", token: "t" };
123
124
  it("reads ids and availability from the server's own shape", async () => {
125
    const served = await withFetch(
126
      (url) => {
127
        expect(url).toBe("http://localhost:4000/api/v3/models");
128
        return new Response(
129
          JSON.stringify({
130
            default: "gpt-5.6-luna",
131
            models: [
132
              { id: "gpt-5.6-luna", availability: "available", default: true },
133
              { id: "ox-alpha", availability: "unavailable", default: false },
134
            ],
135
          }),
136
          { status: 200 },
137
        );
138
      },
139
      () => fetchServedCatalog(api),
140
    );
141
142
    expect(served).toEqual([
143
      { id: "gpt-5.6-luna", available: true, isDefault: true },
144
      { id: "ox-alpha", available: false, isDefault: false },
145
    ]);
146
  });
147
148
  it("treats an availability word it has never seen as not available", async () => {
149
    const served = (await withFetch(
150
      () =>
151
        new Response(JSON.stringify({ models: [{ id: "new", availability: "degraded" }] }), {
152
          status: 200,
153
        }),
154
      () => fetchServedCatalog(api),
155
    )) as readonly { available: boolean }[];
156
157
    expect(served[0]?.available).toBe(false);
158
  });
159
160
  it("cannot answer for a server that refuses the route, rather than reporting an empty catalog", async () => {
161
    // `undefined` is "could not ask", which falls back to the static list. An
162
    // empty list would mean "serves nothing" and would stop the session.
163
    for (const status of [401, 404, 500]) {
164
      const served = await withFetch(
165
        () => new Response("", { status }),
166
        () => fetchServedCatalog(api),
167
      );
168
      expect(served).toBe(undefined);
169
    }
170
  });
171
172
  it("cannot answer for an unreachable server", async () => {
173
    const served = await withFetch(
174
      () => {
175
        throw new Error("ECONNREFUSED");
176
      },
177
      () => fetchServedCatalog(api),
178
    );
179
    expect(served).toBe(undefined);
180
  });
181
});

This page updates live while a promote is in flight · changelog