summaryrefslogtreecommitdiffhomepage
path: root/packages/openai-stream/src/listModels.test.ts
blob: 2e3b1a304e902bcd78123cbdbf6551e8751664b7 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
import type { ApiKeyCredentials, ModelInfo, ProviderContract } from "@dispatch/kernel";
import type { FetchLike } from "@dispatch/trace-replay";
import { describe, expect, it, vi } from "vitest";
import { isVisionModelId, parseModelList } from "./listModels.js";
import { createOpenAICompatProvider } from "./provider.js";

function makeProvider(fetchFn: FetchLike, apiKey = "sk-test-1234567890abcdef"): ProviderContract {
  const creds: ApiKeyCredentials = {
    type: "api-key",
    apiKey,
    baseURL: "https://api.example.com/v1",
  };
  return createOpenAICompatProvider({
    credentials: creds,
    model: "test-model",
    id: "openai-compat",
    fetchFn,
  });
}

function jsonResponse(body: unknown, status = 200): Response {
  return new Response(JSON.stringify(body), {
    status,
    headers: { "Content-Type": "application/json" },
  });
}

describe("listModels — pure mapping (parseModelList)", () => {
  it("maps OpenAI model entries to ModelInfo", () => {
    const result = parseModelList([{ id: "a" }, { id: "b" }]);
    expect(result).toEqual([{ id: "a" }, { id: "b" }]);
  });

  it("returns empty array for empty input", () => {
    const result = parseModelList([]);
    expect(result).toEqual([]);
  });

  it("extracts contextWindow from common field names", () => {
    const result = parseModelList([
      { id: "m1", context_length: 128000 },
      { id: "m2", context_window: 200000 },
      { id: "m3", max_context_length: 64000 },
      { id: "m4", max_tokens: 8000 },
    ]);
    expect(result).toEqual([
      { id: "m1", contextWindow: 128000 },
      { id: "m2", contextWindow: 200000 },
      { id: "m3", contextWindow: 64000 },
      { id: "m4", contextWindow: 8000 },
    ]);
  });
});

describe("listModels — vision capability detection", () => {
  it("isVisionModelId returns true for umans kimi and qwen model ids", () => {
    expect(isVisionModelId("umans-kimi-k2.7")).toBe(true);
    expect(isVisionModelId("Umans-Kimi-K2.7")).toBe(true); // case-insensitive
    expect(isVisionModelId("umans-qwen3.6-35b-a3b")).toBe(true);
  });

  it("isVisionModelId returns false for non-vision model ids", () => {
    expect(isVisionModelId("umans-glm-5.2")).toBe(false);
    expect(isVisionModelId("umans-coder")).toBe(false);
    expect(isVisionModelId("umans-flash")).toBe(false);
    expect(isVisionModelId("kimi-k2.7-code")).toBe(false); // opencode kimi, not umans
    expect(isVisionModelId("qwen3.7-max")).toBe(false); // opencode qwen, not umans
    expect(isVisionModelId("deepseek-v4-flash")).toBe(false);
  });

  it("parseModelList sets vision: true on umans kimi and qwen models only", () => {
    const result = parseModelList([
      { id: "umans-kimi-k2.7", context_length: 262144 },
      { id: "umans-qwen3.6-35b-a3b", context_length: 262144 },
      { id: "umans-glm-5.2", context_length: 405504 },
      { id: "umans-coder" },
    ]);
    expect(result).toEqual([
      { id: "umans-kimi-k2.7", contextWindow: 262144, vision: true },
      { id: "umans-qwen3.6-35b-a3b", contextWindow: 262144, vision: true },
      { id: "umans-glm-5.2", contextWindow: 405504 },
      { id: "umans-coder" },
    ]);
  });
});

describe("listModels — provider contract", () => {
  it("GETs models endpoint with bearer key and returns mapped ModelInfo[]", async () => {
    const fetchFn = vi.fn(
      () => jsonResponse({ data: [{ id: "a" }, { id: "b" }] }) as unknown as ReturnType<FetchLike>,
    );
    const provider = makeProvider(fetchFn);
    const listModels = provider.listModels;
    if (!listModels) throw new Error("listModels not defined");

    const models = await listModels();

    expect(fetchFn).toHaveBeenCalledOnce();
    const callArgs = fetchFn.mock.calls[0];
    if (!callArgs) throw new Error("no call args");
    const [url, init] = callArgs as unknown as [string, RequestInit];
    expect(url).toBe("https://api.example.com/v1/models");
    expect(init.method).toBe("GET");
    expect(init.headers).toEqual({ Authorization: "Bearer sk-test-1234567890abcdef" });

    expect(models).toEqual([{ id: "a" }, { id: "b" }] as readonly ModelInfo[]);
  });

  it("throws on non-OK HTTP status with a clear message", async () => {
    const fetchFn = vi.fn(
      () =>
        new Response("Unauthorized", {
          status: 401,
          headers: { "Content-Type": "text/plain" },
        }) as unknown as ReturnType<FetchLike>,
    );
    const provider = makeProvider(fetchFn);
    const listModels = provider.listModels;
    if (!listModels) throw new Error("listModels not defined");

    await expect(listModels()).rejects.toThrow(
      "listModels[openai-compat]: HTTP 401 — Unauthorized",
    );
  });

  it("throws on network error with a clear message", async () => {
    const fetchFn = vi.fn(() => {
      throw new Error("connection refused");
    }) as unknown as FetchLike;
    const provider = makeProvider(fetchFn);
    const listModels = provider.listModels;
    if (!listModels) throw new Error("listModels not defined");

    await expect(listModels()).rejects.toThrow(
      "listModels[openai-compat]: network error — connection refused",
    );
  });

  it("throws when response shape is missing data array", async () => {
    const fetchFn = vi.fn(() => jsonResponse({ models: [] }) as unknown as ReturnType<FetchLike>);
    const provider = makeProvider(fetchFn);
    const listModels = provider.listModels;
    if (!listModels) throw new Error("listModels not defined");

    await expect(listModels()).rejects.toThrow(
      'listModels[openai-compat]: unexpected response shape — missing "data" array',
    );
  });
});