File size: 5,686 Bytes
dcedb25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
// ── Endpoint identity for custom relay models (#651) ────────────────────────
//
// Every custom relay is stored under platform = 'custom', so two relays that
// both offer `deepseek-v3.1` used to collapse into ONE `models` row: one
// enabled flag, one reliability/speed bucket, one binding anchor. Disabling the
// model because relay A was broken disabled it for relay B too, and relay A's
// timeouts dragged down the copy relay B served just fine (#619).
//
// The discriminator is `models.endpoint_scope`: the normalized base_url of the
// endpoint the row belongs to, '' for every catalog platform. Uniqueness is
// (platform, model_id, endpoint_scope), so catalog rows keep exactly their old
// (platform, model_id) semantics β€” '' is the same value for all of them β€” while
// two relays can each hold their own row for the same model id.
//
// Why base_url and not key_id: #640 settled that an endpoint is a base_url
// holding a POOL of credentials, and a model rotates across that pool. Keying
// identity on key_id would split one relay's model into one row per credential
// and undo that. Why a TEXT token and not a foreign key: the scope is opaque to
// everything except this module, so the per-key model groups of #657 can later
// refine it (`<base_url>#<group>`) without a second identity migration.

import type { Db } from '../db/types.js';

/** Trailing-slash-insensitive endpoint identity, matching how base_url is stored. */
export function normalizeBaseUrl(raw: string): string {
  return raw.trim().replace(/\/+$/, '');
}

/** The scope token for a custom endpoint; '' means "not endpoint-scoped". */
export function endpointScopeForBaseUrl(baseUrl: string | null | undefined): string {
  return baseUrl ? normalizeBaseUrl(baseUrl) : '';
}

/**
 * The scope a model bound to `keyId` belongs to. '' when the key is gone or
 * carries no base_url β€” a legacy custom row that predates per-endpoint binding
 * keeps the un-scoped identity it has always had, so nothing about it changes.
 */
export function endpointScopeOfKey(db: Db, keyId: number | null | undefined): string {
  if (keyId == null) return '';
  const row = db.prepare("SELECT base_url FROM api_keys WHERE id = ? AND platform = 'custom'")
    .get(keyId) as { base_url: string | null } | undefined;
  return endpointScopeForBaseUrl(row?.base_url);
}

/**
 * A short, typable handle for a scope, used only for naming: the host (with
 * port) plus path, slugified. Derived purely from the scope, so it is stable β€”
 * adding or removing OTHER endpoints never renames an existing one.
 *
 *   https://relay-a.example.com/v1  β†’  relay-a.example.com-v1
 *   http://127.0.0.1:11434/v1       β†’  127.0.0.1-11434-v1
 */
export function endpointHandle(scope: string): string {
  const withoutScheme = scope.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
  const slug = withoutScheme
    .toLowerCase()
    .replace(/[^a-z0-9.]+/g, '-')
    .replace(/-+/g, '-')
    .replace(/^[-.]+|[-.]+$/g, '');
  return slug || 'endpoint';
}

/**
 * The bucket key for reliability/speed stats and the routing round-robin.
 * Unchanged (`platform:model_id`) for every catalog model and for un-scoped
 * legacy custom rows; endpoint-qualified only for rows that actually carry a
 * scope, so a single-endpoint install keeps its existing history verbatim.
 */
export function modelStatsKey(
  platform: string,
  modelId: string,
  endpointScope: string | null | undefined,
): string {
  return endpointScope ? `${platform}:${modelId}@${endpointScope}` : `${platform}:${modelId}`;
}

// The separator between a member id and its endpoint handle. '#' reads as a
// fragment and β€” unlike ':', '@' or '/' β€” does not occur in provider model ids.
export const ENDPOINT_ID_SEPARATOR = '#';

/**
 * The endpoint-qualified model id a client can send to pin ONE relay's copy:
 * `custom:deepseek-v3.1#relay-a.example.com-v1`. Null for rows with no scope,
 * whose plain `platform:model_id` member id is already unambiguous β€” which is
 * why a single-endpoint install never sees a qualified id anywhere.
 */
export function qualifiedModelMemberId(
  platform: string,
  modelId: string,
  endpointScope: string | null | undefined,
): string | null {
  if (!endpointScope) return null;
  return `${platform}:${modelId}${ENDPOINT_ID_SEPARATOR}${endpointHandle(endpointScope)}`;
}

/**
 * Whether `requested` names this row's endpoint after the separator. Accepts
 * the handle or the raw base_url (with or without scheme / trailing slash), so
 * a user who pastes the endpoint URL they typed into the dashboard is right.
 */
export function endpointRefMatches(requested: string, endpointScope: string): boolean {
  const ref = requested.trim();
  if (!ref || !endpointScope) return false;
  // Slugifying BOTH sides is what makes the handle and the raw URL equivalent:
  // endpointHandle is idempotent, so handle→handle and url→handle both land on
  // the same token.
  return endpointHandle(ref) === endpointHandle(endpointScope);
}

/**
 * The typable handle for an endpoint GROUP (#1176) β€” the operator's label run
 * through the same slug as endpoint handles, so `My Relays` is `my-relays`.
 * Used after the separator: `custom:deepseek-v3.1#my-relays`.
 */
export function groupHandle(label: string): string {
  return endpointHandle(label);
}

/** Whether `requested` (after the separator) names the group `groupLabel`. */
export function groupRefMatches(requested: string, groupLabel: string | null | undefined): boolean {
  const ref = requested.trim();
  if (!ref || !groupLabel) return false;
  return endpointHandle(ref) === endpointHandle(groupLabel);
}