File size: 4,768 Bytes
eeb9404
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { POST } from '@/app/api/models/route';
import type { NextRequest } from 'next/server';

/**
 * Route-level guard wiring for /api/models. This locks the behavior that regressed once:
 * the SSRF guard must apply to user-supplied custom endpoints but NOT to built-in local
 * providers (which legitimately point at localhost). A guard rejection falls through to
 * `{ models: [] }`, so the meaningful signal is whether the outbound fetch is reached.
 */

function makeReq(body: unknown): NextRequest {
  return { json: async () => body, headers: { get: () => null } } as unknown as NextRequest;
}

function jsonResponse(obj: unknown): Response {
  const text = JSON.stringify(obj);
  return { ok: true, status: 200, json: async () => obj, text: async () => text } as unknown as Response;
}

let fetchMock: ReturnType<typeof vi.fn>;

const MODELS_DEV_FIXTURE = {
  'opencode-go': {
    models: {
      'glm-5.2': {
        id: 'glm-5.2',
        limit: { context: 1000000, output: 131072 },
        modalities: { input: ['text'] },
        status: 'active',
      },
      'minimax-m3': {
        id: 'minimax-m3',
        limit: { context: 1000000, output: 131072 },
        modalities: { input: ['text', 'image', 'video'] },
        status: 'active',
      },
      'glm-5': {
        id: 'glm-5',
        limit: { context: 202752 },
        modalities: { input: ['text'] },
        status: 'deprecated',
      },
    },
  },
};

beforeEach(() => {
  fetchMock = vi.fn(async (url: unknown) => {
    const u = String(url);
    if (u.includes('127.0.0.1:11434')) return jsonResponse({ models: [{ name: 'llama3' }] });
    if (u.includes('api.example.com')) return jsonResponse({ data: [{ id: 'gpt-x' }] });
    if (u.includes('models.dev')) return jsonResponse(MODELS_DEV_FIXTURE);
    return jsonResponse({});
  });
  vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => vi.unstubAllGlobals());

describe('/api/models SSRF guard wiring', () => {
  it('rejects a custom provider on a private/loopback URL — no outbound fetch is made', async () => {
    const res = await POST(makeReq({ provider: 'my-cloud', apiKey: 'sk-x', baseUrl: 'http://169.254.169.254/v1' }));
    const data = await res.json();
    expect(data.models).toEqual([]);          // guard threw → outer catch → empty
    expect(fetchMock).not.toHaveBeenCalled();  // never reached the endpoint
  });

  it('allows a custom provider on a public URL — fetches that endpoint', async () => {
    const res = await POST(makeReq({ provider: 'my-cloud', apiKey: 'sk-x', baseUrl: 'https://api.example.com/v1' }));
    const data = await res.json();
    expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/v1/models', expect.anything());
    expect(data.models).toContain('gpt-x');
  });

  it('does NOT guard built-in local providers — Ollama on localhost still works (regression)', async () => {
    const res = await POST(makeReq({ provider: 'ollama', baseUrl: 'http://localhost:11434/v1' }));
    const data = await res.json();
    expect(fetchMock).toHaveBeenCalledWith('http://127.0.0.1:11434/api/tags');
    expect(data.models).toContain('llama3');
  });
});

describe('/api/models opencode-go discovery', () => {
  it('returns active models from models.dev and excludes deprecated ones', async () => {
    const res = await POST(makeReq({ provider: 'opencode-go', apiKey: 'sk-x' }));
    const data = await res.json();
    expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('models.dev'));
    const ids = data.models.map((m: { id: string }) => m.id);
    expect(ids).toContain('glm-5.2');
    expect(ids).toContain('minimax-m3');
    expect(ids).not.toContain('glm-5');
  });

  it('maps contextLength and inputModalities from models.dev fixture', async () => {
    const res = await POST(makeReq({ provider: 'opencode-go', apiKey: 'sk-x' }));
    const data = await res.json();
    const glm = data.models.find((m: { id: string }) => m.id === 'glm-5.2');
    expect(glm).toBeDefined();
    expect(glm.contextLength).toBe(1000000);
    expect(glm.inputModalities).toEqual(['text']);
    const minimax = data.models.find((m: { id: string }) => m.id === 'minimax-m3');
    expect(minimax).toBeDefined();
    expect(minimax.contextLength).toBe(1000000);
    expect(minimax.inputModalities).toEqual(['text', 'image', 'video']);
  });

  it('returns empty models array gracefully when models.dev fetch fails', async () => {
    fetchMock.mockImplementationOnce(async () => ({ ok: false, status: 503 } as unknown as Response));
    const res = await POST(makeReq({ provider: 'opencode-go', apiKey: 'sk-x' }));
    const data = await res.json();
    expect(data.models).toEqual([]);
  });
});