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 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()
@@ -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())