synckey 0.1.0__py3-none-any.whl
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.
- synckey/__init__.py +3 -0
- synckey/__main__.py +4 -0
- synckey/bucket.py +140 -0
- synckey/cli.py +1481 -0
- synckey/config.py +150 -0
- synckey/context.py +75 -0
- synckey/crypto.py +41 -0
- synckey/db.py +426 -0
- synckey/deferred.py +41 -0
- synckey/keypool.py +139 -0
- synckey/prefs.py +82 -0
- synckey/providers.py +189 -0
- synckey/router.py +291 -0
- synckey/server.py +672 -0
- synckey/state.py +152 -0
- synckey/tiers.py +157 -0
- synckey/usage.py +99 -0
- synckey-0.1.0.dist-info/METADATA +799 -0
- synckey-0.1.0.dist-info/RECORD +21 -0
- synckey-0.1.0.dist-info/WHEEL +4 -0
- synckey-0.1.0.dist-info/entry_points.txt +2 -0
synckey/deferred.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Deferred request queue helpers.
|
|
2
|
+
|
|
3
|
+
When every key for a request is cooling, the gateway can't serve it now. Instead
|
|
4
|
+
of a 503, it persists the request, hands back an id + ETA, and a background worker
|
|
5
|
+
replays it through the normal keypool once capacity frees. The result is stored
|
|
6
|
+
and served on a later GET, then purged `deferred_ttl` seconds after completion.
|
|
7
|
+
|
|
8
|
+
This module holds the small pure helpers; the worker loop and the run path live
|
|
9
|
+
in server.py (they reuse its forwarding logic).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import secrets
|
|
15
|
+
|
|
16
|
+
DEFER_PREFIX = "defer_"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def new_request_id() -> str:
|
|
20
|
+
return DEFER_PREFIX + secrets.token_urlsafe(18)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def estimate_eta(ctx, providers: list[str]) -> float:
|
|
24
|
+
"""Seconds until the soonest key for these providers is expected to be usable.
|
|
25
|
+
|
|
26
|
+
Accounts for both reasons a key can be unavailable: a provider cooldown (use
|
|
27
|
+
its remaining window) and a drained local bucket (use its refill time). Falls
|
|
28
|
+
back to the configured default cooldown when nothing reports a wait.
|
|
29
|
+
"""
|
|
30
|
+
waits: list[float] = []
|
|
31
|
+
for pid in providers:
|
|
32
|
+
for cand in ctx.pool.candidates(pid):
|
|
33
|
+
if cand.ready:
|
|
34
|
+
return 0.0 # something can already go (rare at defer time)
|
|
35
|
+
if cand.cooling and cand.cooldown_remaining > 0:
|
|
36
|
+
waits.append(cand.cooldown_remaining)
|
|
37
|
+
else:
|
|
38
|
+
waits.append(cand.bucket.seconds_to_capacity())
|
|
39
|
+
if waits:
|
|
40
|
+
return max(0.0, min(waits))
|
|
41
|
+
return float(ctx.settings.default_cooldown)
|
synckey/keypool.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Key pool: proactive + tier-aware selection.
|
|
2
|
+
|
|
3
|
+
Selection order for a provider:
|
|
4
|
+
1. LIVE keys whose bucket has capacity (best: no 429 risk)
|
|
5
|
+
2. LIVE keys whose bucket is exhausted (might 429 but key itself is healthy)
|
|
6
|
+
3. COOLING keys, soonest-free first (last resort)
|
|
7
|
+
DEAD keys are never returned.
|
|
8
|
+
|
|
9
|
+
This means the gateway stops sending to a key *before* it 429s, not after.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
from .bucket import Bucket, BucketRegistry
|
|
18
|
+
from .crypto import SecretBox
|
|
19
|
+
from .db import Database, KeyRecord
|
|
20
|
+
from .state import Health, StateStore
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Candidate:
|
|
25
|
+
key_id: int
|
|
26
|
+
label: str
|
|
27
|
+
secret: str
|
|
28
|
+
provider: str
|
|
29
|
+
ready: bool # True if bucket has capacity right now
|
|
30
|
+
cooling: bool
|
|
31
|
+
cooldown_remaining: float
|
|
32
|
+
bucket: Bucket
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_retry_after(value: str | None) -> float | None:
|
|
36
|
+
if not value:
|
|
37
|
+
return None
|
|
38
|
+
try:
|
|
39
|
+
return float(value)
|
|
40
|
+
except ValueError:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class KeyPool:
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
db: Database,
|
|
48
|
+
box: SecretBox,
|
|
49
|
+
states: StateStore,
|
|
50
|
+
default_cooldown: float = 20.0,
|
|
51
|
+
):
|
|
52
|
+
self.db = db
|
|
53
|
+
self.box = box
|
|
54
|
+
self.states = states
|
|
55
|
+
self.default_cooldown = default_cooldown
|
|
56
|
+
self._secrets: dict[int, str] = {}
|
|
57
|
+
self._buckets = BucketRegistry()
|
|
58
|
+
self._cursor: dict[str, int] = {}
|
|
59
|
+
|
|
60
|
+
def _secret(self, rec: KeyRecord) -> str:
|
|
61
|
+
s = self._secrets.get(rec.id)
|
|
62
|
+
if s is None:
|
|
63
|
+
s = self.box.open(rec.secret)
|
|
64
|
+
self._secrets[rec.id] = s
|
|
65
|
+
return s
|
|
66
|
+
|
|
67
|
+
def candidates(self, provider: str, estimated_tokens: int = 0) -> list[Candidate]:
|
|
68
|
+
records = self.db.list_keys(provider=provider, enabled_only=True)
|
|
69
|
+
if not records:
|
|
70
|
+
return []
|
|
71
|
+
|
|
72
|
+
weighted: list[KeyRecord] = []
|
|
73
|
+
for r in records:
|
|
74
|
+
weighted.extend([r] * max(1, r.weight))
|
|
75
|
+
|
|
76
|
+
start = self._cursor.get(provider, 0) % len(weighted)
|
|
77
|
+
self._cursor[provider] = start + 1
|
|
78
|
+
rotated = weighted[start:] + weighted[:start]
|
|
79
|
+
|
|
80
|
+
now = time.time()
|
|
81
|
+
ready: list[Candidate] = []
|
|
82
|
+
throttled: list[Candidate] = []
|
|
83
|
+
cooling: list[Candidate] = []
|
|
84
|
+
seen: set[int] = set()
|
|
85
|
+
|
|
86
|
+
for r in rotated:
|
|
87
|
+
if r.id in seen:
|
|
88
|
+
continue
|
|
89
|
+
seen.add(r.id)
|
|
90
|
+
|
|
91
|
+
st = self.states.get(r.id)
|
|
92
|
+
if st.health == Health.DEAD:
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
bucket = self._buckets.get(r.id, rpm_cap=r.rpm_limit, tpm_cap=r.tpm_limit)
|
|
96
|
+
remaining = max(0.0, st.cooldown_until - now) if st.health == Health.COOLING else 0.0
|
|
97
|
+
is_cooling = remaining > 0
|
|
98
|
+
can_send = bucket.can_send(estimated_tokens)
|
|
99
|
+
|
|
100
|
+
cand = Candidate(
|
|
101
|
+
key_id=r.id,
|
|
102
|
+
label=r.label,
|
|
103
|
+
secret=self._secret(r),
|
|
104
|
+
provider=provider,
|
|
105
|
+
ready=can_send and not is_cooling,
|
|
106
|
+
cooling=is_cooling,
|
|
107
|
+
cooldown_remaining=remaining,
|
|
108
|
+
bucket=bucket,
|
|
109
|
+
)
|
|
110
|
+
if is_cooling:
|
|
111
|
+
cooling.append(cand)
|
|
112
|
+
elif can_send:
|
|
113
|
+
ready.append(cand)
|
|
114
|
+
else:
|
|
115
|
+
throttled.append(cand)
|
|
116
|
+
|
|
117
|
+
cooling.sort(key=lambda c: c.cooldown_remaining)
|
|
118
|
+
return ready + throttled + cooling
|
|
119
|
+
|
|
120
|
+
def on_success(self, key_id: int) -> None:
|
|
121
|
+
self.states.set_live(key_id)
|
|
122
|
+
self._buckets.get(key_id).on_success()
|
|
123
|
+
|
|
124
|
+
def on_rate_limit(self, key_id: int, retry_after: float | None, rpm_at_429: float | None = None) -> None:
|
|
125
|
+
cooldown = retry_after if retry_after and retry_after > 0 else self.default_cooldown
|
|
126
|
+
self.states.set_cooling(key_id, time.time() + cooldown, rpm_observed=rpm_at_429)
|
|
127
|
+
self._buckets.get(key_id).on_rate_limit(retry_after, rpm_at_429)
|
|
128
|
+
|
|
129
|
+
def on_dead(self, key_id: int, reason: str) -> None:
|
|
130
|
+
self.states.set_dead(key_id, reason)
|
|
131
|
+
|
|
132
|
+
def on_failure(self, key_id: int) -> None:
|
|
133
|
+
st = self.states.get(key_id)
|
|
134
|
+
fails = (1 if st.health == Health.LIVE else 2)
|
|
135
|
+
delay = min(self.default_cooldown, 2.0 * fails)
|
|
136
|
+
self.states.set_cooling(key_id, time.time() + delay)
|
|
137
|
+
|
|
138
|
+
def consume(self, key_id: int, estimated_tokens: int = 0) -> None:
|
|
139
|
+
self._buckets.get(key_id).consume(estimated_tokens)
|
synckey/prefs.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""User-managed routing preferences.
|
|
2
|
+
|
|
3
|
+
These are the knobs a user sets interactively (via `synckey setup`, `config`,
|
|
4
|
+
`tier`, or the `key add` flow) rather than hand-editing a file:
|
|
5
|
+
|
|
6
|
+
global_default_model model used when a request names neither model nor provider
|
|
7
|
+
provider_defaults {provider: model} used when a request names only a provider
|
|
8
|
+
aliases {name: target} target is a model id or a tier name
|
|
9
|
+
tier_overrides {model: tier} manual tier assignment
|
|
10
|
+
|
|
11
|
+
They live in DB meta (JSON) so they survive restarts and are editable
|
|
12
|
+
programmatically. config.toml stays the home for *static* infra (host/port,
|
|
13
|
+
custom providers, price overrides) that you version-control; these are the
|
|
14
|
+
*dynamic* choices you tune as you go. `synckey config` shows both in one place.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from .db import Database
|
|
20
|
+
|
|
21
|
+
GLOBAL_DEFAULT = "global_default_model"
|
|
22
|
+
PROVIDER_DEFAULTS = "provider_defaults"
|
|
23
|
+
ALIASES = "aliases"
|
|
24
|
+
TIER_OVERRIDES = "tier_overrides_db"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Prefs:
|
|
28
|
+
def __init__(self, db: Database):
|
|
29
|
+
self.db = db
|
|
30
|
+
|
|
31
|
+
# global default
|
|
32
|
+
def global_default(self) -> str | None:
|
|
33
|
+
return self.db.get_meta(GLOBAL_DEFAULT)
|
|
34
|
+
|
|
35
|
+
def set_global_default(self, model: str) -> None:
|
|
36
|
+
self.db.set_meta(GLOBAL_DEFAULT, model)
|
|
37
|
+
|
|
38
|
+
# per-provider defaults
|
|
39
|
+
def provider_defaults(self) -> dict[str, str]:
|
|
40
|
+
return self.db.get_json(PROVIDER_DEFAULTS, {}) or {}
|
|
41
|
+
|
|
42
|
+
def provider_default(self, provider: str) -> str | None:
|
|
43
|
+
return self.provider_defaults().get(provider.lower())
|
|
44
|
+
|
|
45
|
+
def set_provider_default(self, provider: str, model: str) -> None:
|
|
46
|
+
d = self.provider_defaults()
|
|
47
|
+
d[provider.lower()] = model
|
|
48
|
+
self.db.set_json(PROVIDER_DEFAULTS, d)
|
|
49
|
+
|
|
50
|
+
# aliases
|
|
51
|
+
def aliases(self) -> dict[str, str]:
|
|
52
|
+
return self.db.get_json(ALIASES, {}) or {}
|
|
53
|
+
|
|
54
|
+
def set_alias(self, name: str, target: str) -> None:
|
|
55
|
+
a = self.aliases()
|
|
56
|
+
a[name.lower()] = target
|
|
57
|
+
self.db.set_json(ALIASES, a)
|
|
58
|
+
|
|
59
|
+
def remove_alias(self, name: str) -> bool:
|
|
60
|
+
a = self.aliases()
|
|
61
|
+
if name.lower() in a:
|
|
62
|
+
del a[name.lower()]
|
|
63
|
+
self.db.set_json(ALIASES, a)
|
|
64
|
+
return True
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
# tier overrides
|
|
68
|
+
def tier_overrides(self) -> dict[str, str]:
|
|
69
|
+
return self.db.get_json(TIER_OVERRIDES, {}) or {}
|
|
70
|
+
|
|
71
|
+
def set_tier_override(self, model: str, tier: str) -> None:
|
|
72
|
+
t = self.tier_overrides()
|
|
73
|
+
t[model.lower()] = tier
|
|
74
|
+
self.db.set_json(TIER_OVERRIDES, t)
|
|
75
|
+
|
|
76
|
+
def remove_tier_override(self, model: str) -> bool:
|
|
77
|
+
t = self.tier_overrides()
|
|
78
|
+
if model.lower() in t:
|
|
79
|
+
del t[model.lower()]
|
|
80
|
+
self.db.set_json(TIER_OVERRIDES, t)
|
|
81
|
+
return True
|
|
82
|
+
return False
|
synckey/providers.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Built-in provider registry.
|
|
2
|
+
|
|
3
|
+
Every provider exposes an OpenAI-compatible REST surface, so one adapter (base
|
|
4
|
+
URL plus a bearer key) reaches all of them. Patterns are compiled once at
|
|
5
|
+
construction and only used as a fallback when prefix and live-index routing miss.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Pattern
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Provider:
|
|
17
|
+
id: str
|
|
18
|
+
name: str
|
|
19
|
+
base_url: str
|
|
20
|
+
models_path: str = "/models"
|
|
21
|
+
env: tuple[str, ...] = ()
|
|
22
|
+
patterns: tuple[str, ...] = ()
|
|
23
|
+
auth: str = "bearer" # "bearer" | "x-api-key" | "query:<param>"
|
|
24
|
+
signup: str = ""
|
|
25
|
+
notes: str = ""
|
|
26
|
+
_rx: list[Pattern[str]] = field(default_factory=list, init=False, repr=False, compare=False)
|
|
27
|
+
|
|
28
|
+
def __post_init__(self) -> None:
|
|
29
|
+
self._rx = [re.compile(p, re.IGNORECASE) for p in self.patterns]
|
|
30
|
+
|
|
31
|
+
def matches(self, model: str) -> bool:
|
|
32
|
+
return any(rx.search(model) for rx in self._rx)
|
|
33
|
+
|
|
34
|
+
def auth_headers(self, secret: str) -> dict[str, str]:
|
|
35
|
+
if self.auth == "bearer":
|
|
36
|
+
return {"Authorization": f"Bearer {secret}"}
|
|
37
|
+
if self.auth == "x-api-key":
|
|
38
|
+
return {"x-api-key": secret}
|
|
39
|
+
return {}
|
|
40
|
+
|
|
41
|
+
def auth_params(self, secret: str) -> dict[str, str]:
|
|
42
|
+
if self.auth.startswith("query:"):
|
|
43
|
+
return {self.auth.split(":", 1)[1]: secret}
|
|
44
|
+
return {}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Patterns only claim models a provider unambiguously owns. Shared open-weight
|
|
48
|
+
# names (llama, qwen, deepseek) are resolved at runtime via the live index.
|
|
49
|
+
BUILTIN: dict[str, Provider] = {
|
|
50
|
+
p.id: p
|
|
51
|
+
for p in (
|
|
52
|
+
Provider(
|
|
53
|
+
id="gemini",
|
|
54
|
+
name="Google Gemini",
|
|
55
|
+
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
|
|
56
|
+
env=("GEMINI_API_KEY", "GOOGLE_API_KEY"),
|
|
57
|
+
patterns=(r"^gemini[-/]", r"^gemma[-/]?", r"^learnlm", r"^text-embedding-00"),
|
|
58
|
+
signup="https://aistudio.google.com/apikey",
|
|
59
|
+
),
|
|
60
|
+
Provider(
|
|
61
|
+
id="groq",
|
|
62
|
+
name="Groq",
|
|
63
|
+
base_url="https://api.groq.com/openai/v1",
|
|
64
|
+
env=("GROQ_API_KEY",),
|
|
65
|
+
patterns=(r"versatile$", r"instant$", r"^whisper-large", r"^groq/", r"^gpt-oss", r"^openai/gpt-oss"),
|
|
66
|
+
signup="https://console.groq.com/keys",
|
|
67
|
+
),
|
|
68
|
+
Provider(
|
|
69
|
+
id="cerebras",
|
|
70
|
+
name="Cerebras",
|
|
71
|
+
base_url="https://api.cerebras.ai/v1",
|
|
72
|
+
env=("CEREBRAS_API_KEY",),
|
|
73
|
+
patterns=(r"^llama3\.1-", r"^llama-3\.3-70b$", r"^cerebras/"),
|
|
74
|
+
signup="https://cloud.cerebras.ai",
|
|
75
|
+
),
|
|
76
|
+
Provider(
|
|
77
|
+
id="nvidia",
|
|
78
|
+
name="NVIDIA NIM",
|
|
79
|
+
base_url="https://integrate.api.nvidia.com/v1",
|
|
80
|
+
env=("NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY"),
|
|
81
|
+
patterns=(r"^nvidia/", r"nemotron"),
|
|
82
|
+
signup="https://build.nvidia.com",
|
|
83
|
+
notes="Models are org-namespaced, e.g. meta/llama-3.3-70b-instruct.",
|
|
84
|
+
),
|
|
85
|
+
Provider(
|
|
86
|
+
id="github",
|
|
87
|
+
name="GitHub Models",
|
|
88
|
+
base_url="https://models.github.ai/inference",
|
|
89
|
+
models_path="/catalog/models",
|
|
90
|
+
env=("GITHUB_TOKEN", "GITHUB_MODELS_TOKEN"),
|
|
91
|
+
patterns=(r"^github/",),
|
|
92
|
+
signup="https://github.com/settings/personal-access-tokens",
|
|
93
|
+
notes="Use a fine-grained PAT with the models:read permission.",
|
|
94
|
+
),
|
|
95
|
+
Provider(
|
|
96
|
+
id="cohere",
|
|
97
|
+
name="Cohere",
|
|
98
|
+
base_url="https://api.cohere.ai/compatibility/v1",
|
|
99
|
+
env=("COHERE_API_KEY", "CO_API_KEY"),
|
|
100
|
+
patterns=(r"^command", r"^embed-", r"^rerank", r"^cohere/"),
|
|
101
|
+
signup="https://dashboard.cohere.com/api-keys",
|
|
102
|
+
),
|
|
103
|
+
Provider(
|
|
104
|
+
id="openrouter",
|
|
105
|
+
name="OpenRouter",
|
|
106
|
+
base_url="https://openrouter.ai/api/v1",
|
|
107
|
+
env=("OPENROUTER_API_KEY",),
|
|
108
|
+
patterns=(r"^openrouter/",),
|
|
109
|
+
signup="https://openrouter.ai/keys",
|
|
110
|
+
),
|
|
111
|
+
Provider(
|
|
112
|
+
id="together",
|
|
113
|
+
name="Together AI",
|
|
114
|
+
base_url="https://api.together.xyz/v1",
|
|
115
|
+
env=("TOGETHER_API_KEY",),
|
|
116
|
+
patterns=(r"^together/",),
|
|
117
|
+
signup="https://api.together.ai/settings/api-keys",
|
|
118
|
+
),
|
|
119
|
+
Provider(
|
|
120
|
+
id="mistral",
|
|
121
|
+
name="Mistral AI",
|
|
122
|
+
base_url="https://api.mistral.ai/v1",
|
|
123
|
+
env=("MISTRAL_API_KEY",),
|
|
124
|
+
patterns=(r"^mistral", r"^codestral", r"^ministral", r"^pixtral", r"^magistral"),
|
|
125
|
+
signup="https://console.mistral.ai/api-keys",
|
|
126
|
+
),
|
|
127
|
+
Provider(
|
|
128
|
+
id="deepseek",
|
|
129
|
+
name="DeepSeek",
|
|
130
|
+
base_url="https://api.deepseek.com/v1",
|
|
131
|
+
env=("DEEPSEEK_API_KEY",),
|
|
132
|
+
patterns=(r"^deepseek-chat$", r"^deepseek-reasoner$"),
|
|
133
|
+
signup="https://platform.deepseek.com/api_keys",
|
|
134
|
+
),
|
|
135
|
+
Provider(
|
|
136
|
+
id="xai",
|
|
137
|
+
name="xAI Grok",
|
|
138
|
+
base_url="https://api.x.ai/v1",
|
|
139
|
+
env=("XAI_API_KEY",),
|
|
140
|
+
patterns=(r"^grok",),
|
|
141
|
+
signup="https://console.x.ai",
|
|
142
|
+
),
|
|
143
|
+
Provider(
|
|
144
|
+
id="sambanova",
|
|
145
|
+
name="SambaNova",
|
|
146
|
+
base_url="https://api.sambanova.ai/v1",
|
|
147
|
+
env=("SAMBANOVA_API_KEY",),
|
|
148
|
+
patterns=(r"^sambanova/",),
|
|
149
|
+
signup="https://cloud.sambanova.ai/apis",
|
|
150
|
+
),
|
|
151
|
+
Provider(
|
|
152
|
+
id="openai",
|
|
153
|
+
name="OpenAI",
|
|
154
|
+
base_url="https://api.openai.com/v1",
|
|
155
|
+
env=("OPENAI_API_KEY",),
|
|
156
|
+
patterns=(r"^gpt-", r"^o[0-9]", r"^chatgpt", r"^dall-e", r"^text-embedding-3"),
|
|
157
|
+
signup="https://platform.openai.com/api-keys",
|
|
158
|
+
),
|
|
159
|
+
Provider(
|
|
160
|
+
id="ollama",
|
|
161
|
+
name="Ollama (local)",
|
|
162
|
+
base_url="http://localhost:11434/v1",
|
|
163
|
+
env=("OLLAMA_HOST",),
|
|
164
|
+
patterns=(r"^ollama/",),
|
|
165
|
+
signup="https://ollama.com",
|
|
166
|
+
notes="No key required for a default local install.",
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def split_provider_prefix(model: str, known_ids: set[str]) -> tuple[str | None, str]:
|
|
173
|
+
"""Split a provider/model prefix when the head is a registered provider.
|
|
174
|
+
|
|
175
|
+
Org-namespaced names like meta/llama-3.3-70b are left intact because meta is
|
|
176
|
+
not a registered provider.
|
|
177
|
+
"""
|
|
178
|
+
if "/" in model:
|
|
179
|
+
head, tail = model.split("/", 1)
|
|
180
|
+
if head.lower() in known_ids:
|
|
181
|
+
return head.lower(), tail
|
|
182
|
+
return None, model
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def match_by_pattern(model: str, providers: dict[str, Provider]) -> str | None:
|
|
186
|
+
for pid, prov in providers.items():
|
|
187
|
+
if prov.matches(model):
|
|
188
|
+
return pid
|
|
189
|
+
return None
|