keycall 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.
- keycall/__init__.py +63 -0
- keycall/_cache.py +65 -0
- keycall/_capabilities.py +32 -0
- keycall/_catalog/catalog.json +82 -0
- keycall/_classify.py +59 -0
- keycall/_cli.py +197 -0
- keycall/_client.py +496 -0
- keycall/_credential.py +65 -0
- keycall/_dnsguard.py +135 -0
- keycall/_enums.py +36 -0
- keycall/_errors.py +68 -0
- keycall/_registry.py +207 -0
- keycall/_sanitize.py +46 -0
- keycall/_sources.py +235 -0
- keycall/_tracing.py +124 -0
- keycall/_transport.py +392 -0
- keycall/_types.py +279 -0
- keycall/adapters/__init__.py +33 -0
- keycall/adapters/_anthropic.py +169 -0
- keycall/adapters/_base.py +123 -0
- keycall/adapters/_gemini.py +240 -0
- keycall/adapters/_openai.py +133 -0
- keycall/adapters/_openai_compat.py +127 -0
- keycall/adapters/_perplexity.py +78 -0
- keycall/py.typed +0 -0
- keycall-0.1.0.dist-info/METADATA +141 -0
- keycall-0.1.0.dist-info/RECORD +30 -0
- keycall-0.1.0.dist-info/WHEEL +4 -0
- keycall-0.1.0.dist-info/entry_points.txt +2 -0
- keycall-0.1.0.dist-info/licenses/LICENSE +661 -0
keycall/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""KeyCall: one consistent interface for validating AI-provider API keys,
|
|
2
|
+
listing and filtering their models, and making normalized calls."""
|
|
3
|
+
|
|
4
|
+
from ._client import AsyncKeyCall, KeyCall
|
|
5
|
+
from ._enums import ModelCategory, Operation, ProviderProtocol
|
|
6
|
+
from ._errors import ErrorCode, KeyCallError
|
|
7
|
+
from ._types import (
|
|
8
|
+
AudioInput,
|
|
9
|
+
AudioOutput,
|
|
10
|
+
EmbeddingOutput,
|
|
11
|
+
FileInput,
|
|
12
|
+
FileOutput,
|
|
13
|
+
ImageInput,
|
|
14
|
+
ImageOutput,
|
|
15
|
+
InputPart,
|
|
16
|
+
InvocationResult,
|
|
17
|
+
Message,
|
|
18
|
+
MessageRole,
|
|
19
|
+
Model,
|
|
20
|
+
ModelDiscovery,
|
|
21
|
+
OutputPart,
|
|
22
|
+
TextGenerationRequest,
|
|
23
|
+
TextInput,
|
|
24
|
+
TextOutput,
|
|
25
|
+
TranscriptOutput,
|
|
26
|
+
UnknownOutput,
|
|
27
|
+
Usage,
|
|
28
|
+
VideoOutput,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__version__ = "0.1.0"
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"AsyncKeyCall",
|
|
35
|
+
"AudioInput",
|
|
36
|
+
"AudioOutput",
|
|
37
|
+
"EmbeddingOutput",
|
|
38
|
+
"ErrorCode",
|
|
39
|
+
"FileInput",
|
|
40
|
+
"FileOutput",
|
|
41
|
+
"ImageInput",
|
|
42
|
+
"ImageOutput",
|
|
43
|
+
"InputPart",
|
|
44
|
+
"InvocationResult",
|
|
45
|
+
"KeyCall",
|
|
46
|
+
"KeyCallError",
|
|
47
|
+
"Message",
|
|
48
|
+
"MessageRole",
|
|
49
|
+
"Model",
|
|
50
|
+
"ModelCategory",
|
|
51
|
+
"ModelDiscovery",
|
|
52
|
+
"Operation",
|
|
53
|
+
"OutputPart",
|
|
54
|
+
"ProviderProtocol",
|
|
55
|
+
"TextGenerationRequest",
|
|
56
|
+
"TextInput",
|
|
57
|
+
"TextOutput",
|
|
58
|
+
"TranscriptOutput",
|
|
59
|
+
"UnknownOutput",
|
|
60
|
+
"Usage",
|
|
61
|
+
"VideoOutput",
|
|
62
|
+
"__version__",
|
|
63
|
+
]
|
keycall/_cache.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Credential-dependent availability cache (PRD section 11.1).
|
|
2
|
+
|
|
3
|
+
Process-local, bounded, in-memory, cleared on restart. Keyed by provider +
|
|
4
|
+
base URL + HMAC credential fingerprint — never the raw key or an unkeyed
|
|
5
|
+
digest. Stores the full pre-filter model tuple; category filtering happens
|
|
6
|
+
locally so switching filters never re-contacts the provider.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from collections import OrderedDict
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
|
|
17
|
+
from ._types import Model
|
|
18
|
+
|
|
19
|
+
__all__ = ["AvailabilityCache", "CachedModels"]
|
|
20
|
+
|
|
21
|
+
_MAX_ENTRIES = 64
|
|
22
|
+
DEFAULT_TTL_SECONDS = 300.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
26
|
+
class CachedModels:
|
|
27
|
+
models: tuple[Model, ...]
|
|
28
|
+
fetched_at: datetime
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AvailabilityCache:
|
|
32
|
+
def __init__(self, *, ttl_seconds: float = DEFAULT_TTL_SECONDS) -> None:
|
|
33
|
+
self._ttl = ttl_seconds
|
|
34
|
+
self._lock = threading.Lock()
|
|
35
|
+
self._entries: OrderedDict[tuple[str, str, str], tuple[float, CachedModels]] = OrderedDict()
|
|
36
|
+
|
|
37
|
+
def get(self, provider: str, base_url: str, fingerprint: str) -> CachedModels | None:
|
|
38
|
+
key = (provider, base_url, fingerprint)
|
|
39
|
+
with self._lock:
|
|
40
|
+
entry = self._entries.get(key)
|
|
41
|
+
if entry is None:
|
|
42
|
+
return None
|
|
43
|
+
expires_at, cached = entry
|
|
44
|
+
if time.monotonic() >= expires_at:
|
|
45
|
+
del self._entries[key]
|
|
46
|
+
return None
|
|
47
|
+
self._entries.move_to_end(key)
|
|
48
|
+
return cached
|
|
49
|
+
|
|
50
|
+
def put(self, provider: str, base_url: str, fingerprint: str, cached: CachedModels) -> None:
|
|
51
|
+
key = (provider, base_url, fingerprint)
|
|
52
|
+
with self._lock:
|
|
53
|
+
self._entries[key] = (time.monotonic() + self._ttl, cached)
|
|
54
|
+
self._entries.move_to_end(key)
|
|
55
|
+
while len(self._entries) > _MAX_ENTRIES:
|
|
56
|
+
self._entries.popitem(last=False)
|
|
57
|
+
|
|
58
|
+
def invalidate(self, provider: str, base_url: str, fingerprint: str) -> None:
|
|
59
|
+
with self._lock:
|
|
60
|
+
self._entries.pop((provider, base_url, fingerprint), None)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# One process-local cache shared by all clients, matching the process-local
|
|
64
|
+
# HMAC fingerprint secret.
|
|
65
|
+
shared_cache = AvailabilityCache()
|
keycall/_capabilities.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Maintained per-model capability evidence (PRD section 8).
|
|
2
|
+
|
|
3
|
+
Small and deliberately conservative: an entry here blocks a request before
|
|
4
|
+
it reaches the provider, so entries require confirmed evidence that the
|
|
5
|
+
provider rejects the parameter. Wrong entries block valid calls — when
|
|
6
|
+
unsure, leave the model out and let the provider's own 400 answer.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
__all__ = ["rejects_sampling_params"]
|
|
14
|
+
|
|
15
|
+
# Model families whose APIs reject temperature/top_p outright.
|
|
16
|
+
# OpenAI reasoning families are documented: o-series and gpt-5 accept only
|
|
17
|
+
# the default temperature and error on an explicit value.
|
|
18
|
+
_SAMPLING_REJECTING = (
|
|
19
|
+
re.compile(r"^o\d"), # o1, o3, o4-mini, ...
|
|
20
|
+
re.compile(r"^gpt-5"), # gpt-5 family (Responses API)
|
|
21
|
+
# Anthropic deprecated temperature/top_p/top_k for newer reasoning and
|
|
22
|
+
# Opus models: sending them returns 400. Claude Opus 4.7+ and the newer
|
|
23
|
+
# Sonnet generations are affected; behavior is now controlled by prompting.
|
|
24
|
+
re.compile(r"^claude-opus-4-[7-9]"),
|
|
25
|
+
re.compile(r"^claude-opus-[5-9]"),
|
|
26
|
+
re.compile(r"^claude-sonnet-[5-9]"),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def rejects_sampling_params(model_id: str) -> bool:
|
|
31
|
+
lowered = model_id.lower()
|
|
32
|
+
return any(pattern.match(lowered) for pattern in _SAMPLING_REJECTING)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": "1",
|
|
3
|
+
"catalog_version": "2026.08.05.1",
|
|
4
|
+
"verified_at": "2026-08-03T00:00:00Z",
|
|
5
|
+
"providers": {
|
|
6
|
+
"openai": {
|
|
7
|
+
"aliases": [],
|
|
8
|
+
"protocol": "openai",
|
|
9
|
+
"base_url": "https://api.openai.com/v1",
|
|
10
|
+
"auth": { "scheme": "bearer", "header": "Authorization" },
|
|
11
|
+
"operations": {
|
|
12
|
+
"list_models": { "method": "GET", "path": "/models" },
|
|
13
|
+
"text_generation": { "method": "POST", "path": "/responses" }
|
|
14
|
+
},
|
|
15
|
+
"provider_request_id_header": "x-request-id"
|
|
16
|
+
},
|
|
17
|
+
"anthropic": {
|
|
18
|
+
"aliases": ["claude"],
|
|
19
|
+
"protocol": "anthropic",
|
|
20
|
+
"base_url": "https://api.anthropic.com",
|
|
21
|
+
"auth": { "scheme": "api_key", "header": "x-api-key" },
|
|
22
|
+
"api_version_header": { "name": "anthropic-version", "value": "2023-06-01" },
|
|
23
|
+
"operations": {
|
|
24
|
+
"list_models": { "method": "GET", "path": "/v1/models" },
|
|
25
|
+
"text_generation": { "method": "POST", "path": "/v1/messages" }
|
|
26
|
+
},
|
|
27
|
+
"provider_request_id_header": "request-id"
|
|
28
|
+
},
|
|
29
|
+
"gemini": {
|
|
30
|
+
"aliases": ["google", "google-gemini"],
|
|
31
|
+
"protocol": "gemini",
|
|
32
|
+
"base_url": "https://generativelanguage.googleapis.com/v1beta",
|
|
33
|
+
"auth": { "scheme": "api_key", "header": "x-goog-api-key" },
|
|
34
|
+
"operations": {
|
|
35
|
+
"list_models": { "method": "GET", "path": "/models" },
|
|
36
|
+
"text_generation": { "method": "POST", "path": "/models/{model}:generateContent" }
|
|
37
|
+
},
|
|
38
|
+
"provider_request_id_header": null
|
|
39
|
+
},
|
|
40
|
+
"deepseek": {
|
|
41
|
+
"aliases": [],
|
|
42
|
+
"protocol": "openai-compatible",
|
|
43
|
+
"base_url": "https://api.deepseek.com",
|
|
44
|
+
"auth": { "scheme": "bearer", "header": "Authorization" },
|
|
45
|
+
"operations": {
|
|
46
|
+
"list_models": { "method": "GET", "path": "/models" },
|
|
47
|
+
"text_generation": { "method": "POST", "path": "/chat/completions" }
|
|
48
|
+
},
|
|
49
|
+
"provider_request_id_header": null
|
|
50
|
+
},
|
|
51
|
+
"perplexity": {
|
|
52
|
+
"aliases": ["pplx"],
|
|
53
|
+
"protocol": "openai-compatible",
|
|
54
|
+
"base_url": "https://api.perplexity.ai",
|
|
55
|
+
"auth": { "scheme": "bearer", "header": "Authorization" },
|
|
56
|
+
"operations": {
|
|
57
|
+
"list_models": { "method": "GET", "path": "/v1/models" },
|
|
58
|
+
"text_generation": { "method": "POST", "path": "/v1/sonar" }
|
|
59
|
+
},
|
|
60
|
+
"provider_request_id_header": null,
|
|
61
|
+
"model_discovery": "catalog",
|
|
62
|
+
"model_discovery_note": "GET /v1/models is scoped to the Agent API: it returns vendor-prefixed router models (anthropic/..., perplexity/sonar) that POST /v1/sonar rejects. Sonar models are not API-discoverable, so they are maintained here. The list call still runs to validate the credential. Verified live 2026-08-05.",
|
|
63
|
+
"models": [
|
|
64
|
+
{ "id": "sonar", "categories": ["text_generation"] },
|
|
65
|
+
{ "id": "sonar-pro", "categories": ["text_generation"] },
|
|
66
|
+
{ "id": "sonar-reasoning-pro", "categories": ["text_generation"] }
|
|
67
|
+
],
|
|
68
|
+
"min_max_output_tokens": 16
|
|
69
|
+
},
|
|
70
|
+
"moonshot": {
|
|
71
|
+
"aliases": ["kimi"],
|
|
72
|
+
"protocol": "openai-compatible",
|
|
73
|
+
"base_url": "https://api.moonshot.ai/v1",
|
|
74
|
+
"auth": { "scheme": "bearer", "header": "Authorization" },
|
|
75
|
+
"operations": {
|
|
76
|
+
"list_models": { "method": "GET", "path": "/models" },
|
|
77
|
+
"text_generation": { "method": "POST", "path": "/chat/completions" }
|
|
78
|
+
},
|
|
79
|
+
"provider_request_id_header": null
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
keycall/_classify.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Conservative model classification (PRD section 8).
|
|
2
|
+
|
|
3
|
+
Precedence: explicit provider metadata first (the Gemini adapter passes
|
|
4
|
+
supported generation methods through), then maintained identifier rules.
|
|
5
|
+
Conflicts and unknowns resolve to UNKNOWN — an unknown model must never
|
|
6
|
+
silently enter the default text picker.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ._enums import ModelCategory
|
|
12
|
+
|
|
13
|
+
__all__ = ["classify_model_id"]
|
|
14
|
+
|
|
15
|
+
# Ordered: first match wins. More specific non-text signals come before the
|
|
16
|
+
# broad text-family patterns so "gpt-image-1" never classifies as text.
|
|
17
|
+
_RULES: tuple[tuple[tuple[str, ...], ModelCategory], ...] = (
|
|
18
|
+
(("embed",), ModelCategory.EMBEDDING),
|
|
19
|
+
(("whisper", "transcribe"), ModelCategory.TRANSCRIPTION),
|
|
20
|
+
(("tts", "speech"), ModelCategory.SPEECH_GENERATION),
|
|
21
|
+
(("realtime",), ModelCategory.REALTIME),
|
|
22
|
+
(("dall-e", "image", "imagen", "flux"), ModelCategory.IMAGE_GENERATION),
|
|
23
|
+
(("sora", "veo"), ModelCategory.VIDEO_GENERATION),
|
|
24
|
+
# Ambiguous or out-of-taxonomy families stay unknown rather than
|
|
25
|
+
# guessing: moderation/reranking/guard models, audio-hybrid previews.
|
|
26
|
+
(("moderation", "rerank", "guard", "audio"), ModelCategory.UNKNOWN),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_TEXT_MARKERS: tuple[str, ...] = (
|
|
30
|
+
"gpt",
|
|
31
|
+
"chatgpt",
|
|
32
|
+
"claude",
|
|
33
|
+
"gemini",
|
|
34
|
+
"gemma",
|
|
35
|
+
"deepseek",
|
|
36
|
+
"sonar",
|
|
37
|
+
"moonshot",
|
|
38
|
+
"kimi",
|
|
39
|
+
"mistral",
|
|
40
|
+
"llama",
|
|
41
|
+
"qwen",
|
|
42
|
+
"glm",
|
|
43
|
+
"grok",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def classify_model_id(model_id: str) -> ModelCategory:
|
|
48
|
+
"""Classify by identifier rules alone. Adapters with provider metadata
|
|
49
|
+
should prefer that evidence and only fall back here."""
|
|
50
|
+
lowered = model_id.lower()
|
|
51
|
+
for markers, category in _RULES:
|
|
52
|
+
if any(marker in lowered for marker in markers):
|
|
53
|
+
return category
|
|
54
|
+
if any(marker in lowered for marker in _TEXT_MARKERS):
|
|
55
|
+
return ModelCategory.TEXT_GENERATION
|
|
56
|
+
# OpenAI reasoning families: o1, o3, o4-mini, ...
|
|
57
|
+
if len(lowered) >= 2 and lowered[0] == "o" and lowered[1].isdigit():
|
|
58
|
+
return ModelCategory.TEXT_GENERATION
|
|
59
|
+
return ModelCategory.UNKNOWN
|
keycall/_cli.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""keycall CLI: live credential verification (PRD section 14.2).
|
|
2
|
+
|
|
3
|
+
Each target gets one model-list call. With --generate, KeyCall walks the
|
|
4
|
+
filtered text models in provider order and reports the outcome of every
|
|
5
|
+
attempt until one succeeds or the attempt budget runs out.
|
|
6
|
+
|
|
7
|
+
This is *reported* fallthrough, not the silent fallthrough PRD section 14.3
|
|
8
|
+
forbids: each skipped model is printed with the reason it was skipped, so
|
|
9
|
+
provider drift (retired models still advertised, modality mismatches, quota
|
|
10
|
+
walls) stays visible instead of being masked. A credential failure stops
|
|
11
|
+
immediately — no point trying more models with a key the provider rejected.
|
|
12
|
+
|
|
13
|
+
Keys never appear in output.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
from ._enums import ModelCategory
|
|
22
|
+
from ._errors import ErrorCode, KeyCallError
|
|
23
|
+
from ._sanitize import safe_display_name
|
|
24
|
+
from ._sources import SourceError, Target, load_targets, remind_deletion
|
|
25
|
+
|
|
26
|
+
_GENERATION_PROMPT = "Reply with the single word: ok"
|
|
27
|
+
_GENERATION_MAX_TOKENS = 16
|
|
28
|
+
_DEFAULT_ATTEMPTS = 8
|
|
29
|
+
|
|
30
|
+
# The credential itself is the problem: stop immediately, since no other
|
|
31
|
+
# model will fare better with a key the provider has rejected.
|
|
32
|
+
_CREDENTIAL_FAILURES = frozenset(
|
|
33
|
+
{ErrorCode.INVALID_API_KEY, ErrorCode.PERMISSION_DENIED}
|
|
34
|
+
)
|
|
35
|
+
# Everything else is model-scoped and worth trying the next candidate for.
|
|
36
|
+
# Rate limits included, deliberately: providers meter per model and tier, so
|
|
37
|
+
# a 429 on one model says nothing about the next (Gemini free tier gives
|
|
38
|
+
# 2.5-pro zero quota while flash-latest answers fine). Exhausting the budget
|
|
39
|
+
# on rate limits is reported as unverified, never as a failed adapter
|
|
40
|
+
# (PRD 14.2: rate limits are distinct from adapter incompatibility).
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _verify_target(
|
|
44
|
+
target: Target, *, generate: bool, attempts: int = _DEFAULT_ATTEMPTS
|
|
45
|
+
) -> tuple[bool, list[str]]:
|
|
46
|
+
"""Returns (ok, report_lines). Never raises for provider failures."""
|
|
47
|
+
from ._client import KeyCall
|
|
48
|
+
from ._types import Message, TextInput
|
|
49
|
+
|
|
50
|
+
label = target.display_name
|
|
51
|
+
lines = []
|
|
52
|
+
try:
|
|
53
|
+
with KeyCall(
|
|
54
|
+
provider=target.provider,
|
|
55
|
+
api_key=target.key,
|
|
56
|
+
protocol=target.protocol,
|
|
57
|
+
base_url=target.base_url,
|
|
58
|
+
) as client:
|
|
59
|
+
# Verification must hit the live provider, never cached data.
|
|
60
|
+
discovery = client.list_models(
|
|
61
|
+
categories={ModelCategory.TEXT_GENERATION}, refresh=True
|
|
62
|
+
)
|
|
63
|
+
text_models = discovery.models
|
|
64
|
+
lines.append(
|
|
65
|
+
f"✓ {label} ({client.provider}): key accepted, "
|
|
66
|
+
f"{len(text_models)} text model(s)"
|
|
67
|
+
)
|
|
68
|
+
if not generate:
|
|
69
|
+
return True, lines
|
|
70
|
+
if not text_models:
|
|
71
|
+
lines.append(f"✗ {label}: no text models available to generate with")
|
|
72
|
+
return False, lines
|
|
73
|
+
|
|
74
|
+
messages = [Message(role="user", content=[TextInput(text=_GENERATION_PROMPT)])]
|
|
75
|
+
rate_limited = False
|
|
76
|
+
for position, candidate in enumerate(text_models[:attempts]):
|
|
77
|
+
try:
|
|
78
|
+
result = client.generate_text(
|
|
79
|
+
model=candidate.id,
|
|
80
|
+
messages=messages,
|
|
81
|
+
max_output_tokens=_GENERATION_MAX_TOKENS,
|
|
82
|
+
)
|
|
83
|
+
except KeyCallError as error:
|
|
84
|
+
lines.append(
|
|
85
|
+
f" ✗ {candidate.id} (position {position}): "
|
|
86
|
+
f"{error.code.value} — {error.message}"
|
|
87
|
+
)
|
|
88
|
+
if error.code in _CREDENTIAL_FAILURES:
|
|
89
|
+
lines.append(f"✗ {label}: credential rejected")
|
|
90
|
+
return False, lines
|
|
91
|
+
if error.code is ErrorCode.RATE_LIMITED:
|
|
92
|
+
rate_limited = True
|
|
93
|
+
continue
|
|
94
|
+
usage = result.usage.total_tokens
|
|
95
|
+
skipped = f", {position} advertised model(s) skipped" if position else ""
|
|
96
|
+
lines.append(
|
|
97
|
+
f"✓ {label}: generated with {candidate.id} "
|
|
98
|
+
f"(filtered position {position}{skipped}, "
|
|
99
|
+
f"{result.round_trip_duration_ms:.0f} ms, "
|
|
100
|
+
f"total tokens: {usage if usage is not None else 'unreported'})"
|
|
101
|
+
)
|
|
102
|
+
return True, lines
|
|
103
|
+
|
|
104
|
+
tried = min(attempts, len(text_models))
|
|
105
|
+
if rate_limited:
|
|
106
|
+
lines.append(
|
|
107
|
+
f"! {label}: generation unverified — quota/rate limited "
|
|
108
|
+
f"({tried} attempted of {len(text_models)})"
|
|
109
|
+
)
|
|
110
|
+
else:
|
|
111
|
+
lines.append(
|
|
112
|
+
f"✗ {label}: no advertised text model was invocable "
|
|
113
|
+
f"({tried} attempted of {len(text_models)})"
|
|
114
|
+
)
|
|
115
|
+
return False, lines
|
|
116
|
+
except KeyCallError as error:
|
|
117
|
+
lines.append(
|
|
118
|
+
f"✗ {label} ({target.provider}): {error.code.value} — {error.message}"
|
|
119
|
+
+ (" [retryable]" if error.retryable else "")
|
|
120
|
+
)
|
|
121
|
+
return False, lines
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _run_verify(args: argparse.Namespace) -> int:
|
|
125
|
+
try:
|
|
126
|
+
targets, warnings = load_targets(
|
|
127
|
+
args.source or "-",
|
|
128
|
+
provider=args.provider,
|
|
129
|
+
protocol=args.protocol,
|
|
130
|
+
base_url=args.base_url,
|
|
131
|
+
)
|
|
132
|
+
except SourceError as error:
|
|
133
|
+
print(f"error: {error}", file=sys.stderr)
|
|
134
|
+
return 2
|
|
135
|
+
|
|
136
|
+
for warning in warnings:
|
|
137
|
+
if args.strict_credentials:
|
|
138
|
+
print(f"error (strict): {warning.message}", file=sys.stderr)
|
|
139
|
+
return 2
|
|
140
|
+
print(f"warning: {warning.message}", file=sys.stderr)
|
|
141
|
+
|
|
142
|
+
all_ok = True
|
|
143
|
+
for target in targets:
|
|
144
|
+
ok, lines = _verify_target(target, generate=args.generate, attempts=args.attempts)
|
|
145
|
+
all_ok = all_ok and ok
|
|
146
|
+
for line in lines:
|
|
147
|
+
print(safe_display_name(line, max_length=300))
|
|
148
|
+
|
|
149
|
+
if args.source:
|
|
150
|
+
reminder = remind_deletion(args.source)
|
|
151
|
+
if reminder:
|
|
152
|
+
print(reminder, file=sys.stderr)
|
|
153
|
+
|
|
154
|
+
return 0 if all_ok else 1
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def main(argv: list[str] | None = None) -> int:
|
|
158
|
+
parser = argparse.ArgumentParser(prog="keycall")
|
|
159
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
160
|
+
|
|
161
|
+
verify = subparsers.add_parser(
|
|
162
|
+
"verify", help="verify credentials against live providers"
|
|
163
|
+
)
|
|
164
|
+
verify.add_argument(
|
|
165
|
+
"--source",
|
|
166
|
+
"-s",
|
|
167
|
+
help="TXT/JSON/TOML target file, env:VAR_NAME, or omit for interactive prompt",
|
|
168
|
+
)
|
|
169
|
+
verify.add_argument("--provider", help="provider name (env:/interactive sources)")
|
|
170
|
+
verify.add_argument("--protocol", help="protocol override (custom targets)")
|
|
171
|
+
verify.add_argument("--base-url", dest="base_url", help="base URL (custom targets)")
|
|
172
|
+
verify.add_argument(
|
|
173
|
+
"--generate",
|
|
174
|
+
action="store_true",
|
|
175
|
+
help="also make one bounded text generation per target",
|
|
176
|
+
)
|
|
177
|
+
verify.add_argument(
|
|
178
|
+
"--attempts",
|
|
179
|
+
type=int,
|
|
180
|
+
default=_DEFAULT_ATTEMPTS,
|
|
181
|
+
help=f"max models to try per target with --generate (default {_DEFAULT_ATTEMPTS})",
|
|
182
|
+
)
|
|
183
|
+
verify.add_argument(
|
|
184
|
+
"--strict-credentials",
|
|
185
|
+
action="store_true",
|
|
186
|
+
help="treat credential-file warnings as errors",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
args = parser.parse_args(argv)
|
|
190
|
+
if args.command == "verify":
|
|
191
|
+
return _run_verify(args)
|
|
192
|
+
parser.print_help()
|
|
193
|
+
return 0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
if __name__ == "__main__":
|
|
197
|
+
raise SystemExit(main())
|