oy-cli 0.3.4__tar.gz → 0.3.5__tar.gz
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.
- {oy_cli-0.3.4 → oy_cli-0.3.5}/PKG-INFO +1 -1
- {oy_cli-0.3.4 → oy_cli-0.3.5}/oy_cli.egg-info/PKG-INFO +1 -1
- {oy_cli-0.3.4 → oy_cli-0.3.5}/oy_cli.py +64 -13
- {oy_cli-0.3.4 → oy_cli-0.3.5}/pyproject.toml +1 -1
- {oy_cli-0.3.4 → oy_cli-0.3.5}/shim.py +162 -0
- {oy_cli-0.3.4 → oy_cli-0.3.5}/LICENSE +0 -0
- {oy_cli-0.3.4 → oy_cli-0.3.5}/README.md +0 -0
- {oy_cli-0.3.4 → oy_cli-0.3.5}/oy_cli.egg-info/SOURCES.txt +0 -0
- {oy_cli-0.3.4 → oy_cli-0.3.5}/oy_cli.egg-info/dependency_links.txt +0 -0
- {oy_cli-0.3.4 → oy_cli-0.3.5}/oy_cli.egg-info/entry_points.txt +0 -0
- {oy_cli-0.3.4 → oy_cli-0.3.5}/oy_cli.egg-info/requires.txt +0 -0
- {oy_cli-0.3.4 → oy_cli-0.3.5}/oy_cli.egg-info/top_level.txt +0 -0
- {oy_cli-0.3.4 → oy_cli-0.3.5}/setup.cfg +0 -0
- {oy_cli-0.3.4 → oy_cli-0.3.5}/tests/test_oy_cli.py +0 -0
- {oy_cli-0.3.4 → oy_cli-0.3.5}/tests/test_shim.py +0 -0
|
@@ -256,7 +256,7 @@ def abort(m, c=1):
|
|
|
256
256
|
def clip_tokens(text, limit=MAX_TOOL_OUTPUT_TOKENS, tail=0):
|
|
257
257
|
"""Truncate *text* to *limit* tokens, optionally keeping *tail* tokens from the end."""
|
|
258
258
|
e = get_tokenizer()
|
|
259
|
-
ids = e.encode(text)
|
|
259
|
+
ids = e.encode(text, disallowed_special=())
|
|
260
260
|
n = len(ids)
|
|
261
261
|
if n <= limit:
|
|
262
262
|
return text
|
|
@@ -1536,7 +1536,7 @@ def truncate_str_to_tokens(text: str, max_tokens: int = MAX_MESSAGE_TOKENS) -> s
|
|
|
1536
1536
|
characters were removed so the model knows the content was cut.
|
|
1537
1537
|
"""
|
|
1538
1538
|
enc = get_tokenizer()
|
|
1539
|
-
ids = enc.encode(text)
|
|
1539
|
+
ids = enc.encode(text, disallowed_special=())
|
|
1540
1540
|
if len(ids) <= max_tokens:
|
|
1541
1541
|
return text
|
|
1542
1542
|
kept = enc.decode(ids[:max_tokens])
|
|
@@ -1798,19 +1798,69 @@ def _setup_readline():
|
|
|
1798
1798
|
atexit.register(readline.write_history_file, str(history_path))
|
|
1799
1799
|
|
|
1800
1800
|
|
|
1801
|
+
def _drain_stdin(timeout: float = 0.05) -> str:
|
|
1802
|
+
"""Read any data already buffered on stdin (e.g. the tail of a paste).
|
|
1803
|
+
|
|
1804
|
+
Uses select() with a short timeout. Returns the extra text, or "".
|
|
1805
|
+
Only works on real ttys; returns "" for piped stdin.
|
|
1806
|
+
"""
|
|
1807
|
+
import select
|
|
1808
|
+
if not sys.stdin.isatty():
|
|
1809
|
+
return ""
|
|
1810
|
+
chunks: list[str] = []
|
|
1811
|
+
while True:
|
|
1812
|
+
ready, _, _ = select.select([sys.stdin], [], [], timeout)
|
|
1813
|
+
if not ready:
|
|
1814
|
+
break
|
|
1815
|
+
chunk = os.read(sys.stdin.fileno(), 4096)
|
|
1816
|
+
if not chunk:
|
|
1817
|
+
break
|
|
1818
|
+
chunks.append(chunk.decode("utf-8", errors="replace"))
|
|
1819
|
+
# After first chunk, use a tighter timeout for the rest.
|
|
1820
|
+
timeout = 0.01
|
|
1821
|
+
return "".join(chunks)
|
|
1822
|
+
|
|
1823
|
+
|
|
1801
1824
|
def _read_input():
|
|
1802
|
-
|
|
1825
|
+
'''Read user input, with automatic paste detection.
|
|
1826
|
+
|
|
1827
|
+
Input modes:
|
|
1828
|
+
1. Single line -- type and press Enter.
|
|
1829
|
+
2. Paste -- paste multiline text; lines that arrive within a
|
|
1830
|
+
few milliseconds of Enter are collected automatically.
|
|
1831
|
+
3. Block mode -- start with ``"""`` to open a fenced block;
|
|
1832
|
+
close it with ``"""`` on its own line.
|
|
1833
|
+
|
|
1834
|
+
Paste detection works by draining stdin right after readline returns.
|
|
1835
|
+
During normal typing there is nothing buffered, so it is a no-op.
|
|
1836
|
+
During a paste, the remaining lines are already queued up.
|
|
1837
|
+
'''
|
|
1803
1838
|
line = input("oy > ")
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1839
|
+
|
|
1840
|
+
# --- block mode: triple-quote fence (still supported) ------------------
|
|
1841
|
+
stripped = line.strip()
|
|
1842
|
+
if stripped == '"""' or stripped.startswith('"""'):
|
|
1843
|
+
if stripped == '"""':
|
|
1844
|
+
parts: list[str] = []
|
|
1845
|
+
else:
|
|
1846
|
+
parts = [stripped[3:]]
|
|
1847
|
+
while True:
|
|
1848
|
+
try:
|
|
1849
|
+
cont = input('... ')
|
|
1850
|
+
except EOFError:
|
|
1851
|
+
break
|
|
1852
|
+
if cont.strip() == '"""':
|
|
1853
|
+
break
|
|
1810
1854
|
parts.append(cont)
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1855
|
+
return "\n".join(parts)
|
|
1856
|
+
|
|
1857
|
+
# --- paste detection: drain any remaining buffered input ---------------
|
|
1858
|
+
extra = _drain_stdin()
|
|
1859
|
+
if extra:
|
|
1860
|
+
# Strip trailing newline that the terminal added from the final Enter.
|
|
1861
|
+
return line + "\n" + extra.rstrip("\n")
|
|
1862
|
+
|
|
1863
|
+
return line
|
|
1814
1864
|
|
|
1815
1865
|
|
|
1816
1866
|
|
|
@@ -1879,7 +1929,8 @@ def _chat_command(cmd, transcript, system_prompt, model_spec):
|
|
|
1879
1929
|
"- `/clear` -- reset conversation (keeps system prompt)",
|
|
1880
1930
|
"- `/quit` or `/exit` -- end session",
|
|
1881
1931
|
"",
|
|
1882
|
-
"Tip:
|
|
1932
|
+
"Tip: paste multiline text — extra lines are detected automatically.",
|
|
1933
|
+
'Tip: type `"""` to start a multiline block, `"""` to end it.',
|
|
1883
1934
|
]), err=True)
|
|
1884
1935
|
return True
|
|
1885
1936
|
if cmd == "/tokens":
|
|
@@ -36,6 +36,7 @@ SHIM_GEMINI = "gemini"
|
|
|
36
36
|
SHIM_BEDROCK = "bedrock"
|
|
37
37
|
SHIM_MANTLE = "bedrock-mantle"
|
|
38
38
|
SHIM_CLAUDE = "claude"
|
|
39
|
+
SHIM_COPILOT = "copilot"
|
|
39
40
|
SHIM_ORDER = (
|
|
40
41
|
SHIM_OPENAI,
|
|
41
42
|
SHIM_CODEX,
|
|
@@ -43,6 +44,7 @@ SHIM_ORDER = (
|
|
|
43
44
|
SHIM_BEDROCK,
|
|
44
45
|
SHIM_MANTLE,
|
|
45
46
|
SHIM_CLAUDE,
|
|
47
|
+
SHIM_COPILOT,
|
|
46
48
|
)
|
|
47
49
|
KNOWN_SHIMS = set(SHIM_ORDER)
|
|
48
50
|
|
|
@@ -2486,6 +2488,160 @@ def _list_mantle_models(
|
|
|
2486
2488
|
return _build_mantle_client(region, cwd).list_models()
|
|
2487
2489
|
|
|
2488
2490
|
|
|
2491
|
+
# ---------------------------------------------------------------------------
|
|
2492
|
+
# Copilot shim – uses the GitHub Copilot API (api.githubcopilot.com)
|
|
2493
|
+
# with a GitHub PAT obtained from COPILOT_GITHUB_TOKEN / GH_TOKEN /
|
|
2494
|
+
# GITHUB_TOKEN / `gh auth token`. Set COPILOT_BASE_URL to override
|
|
2495
|
+
# (e.g. https://api.business.githubcopilot.com for enterprise).
|
|
2496
|
+
# Models that support /responses use that API; others fall back to
|
|
2497
|
+
# /chat/completions automatically.
|
|
2498
|
+
# ---------------------------------------------------------------------------
|
|
2499
|
+
|
|
2500
|
+
_COPILOT_BASE_URL = os.environ.get(
|
|
2501
|
+
"COPILOT_BASE_URL", "https://api.githubcopilot.com"
|
|
2502
|
+
)
|
|
2503
|
+
_COPILOT_INTEGRATION_ID = "copilot-developer-cli"
|
|
2504
|
+
_COPILOT_EDITOR_VERSION = "copilot-developer-cli/1.0.6"
|
|
2505
|
+
|
|
2506
|
+
|
|
2507
|
+
def _get_github_token() -> str | None:
|
|
2508
|
+
"""Return a GitHub PAT from env vars or the ``gh`` CLI, or *None*.
|
|
2509
|
+
|
|
2510
|
+
Checks (in order): ``COPILOT_GITHUB_TOKEN``, ``GH_TOKEN``,
|
|
2511
|
+
``GITHUB_TOKEN``, then ``gh auth token``.
|
|
2512
|
+
"""
|
|
2513
|
+
for var in ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"):
|
|
2514
|
+
val = os.environ.get(var)
|
|
2515
|
+
if isinstance(val, str) and val:
|
|
2516
|
+
return val
|
|
2517
|
+
gh = which("gh")
|
|
2518
|
+
if not gh:
|
|
2519
|
+
return None
|
|
2520
|
+
try:
|
|
2521
|
+
proc = subprocess.run(
|
|
2522
|
+
[gh, "auth", "token"],
|
|
2523
|
+
capture_output=True,
|
|
2524
|
+
text=True,
|
|
2525
|
+
timeout=10,
|
|
2526
|
+
)
|
|
2527
|
+
token = proc.stdout.strip()
|
|
2528
|
+
return token if proc.returncode == 0 and token else None
|
|
2529
|
+
except Exception:
|
|
2530
|
+
return None
|
|
2531
|
+
|
|
2532
|
+
|
|
2533
|
+
def _copilot_default_headers() -> dict[str, str]:
|
|
2534
|
+
return {
|
|
2535
|
+
"Copilot-Integration-Id": _COPILOT_INTEGRATION_ID,
|
|
2536
|
+
"Editor-Version": _COPILOT_EDITOR_VERSION,
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
|
|
2540
|
+
def _copilot_openai_pair(token: str) -> tuple[AsyncOpenAI, OpenAI]:
|
|
2541
|
+
kwargs: dict[str, Any] = {
|
|
2542
|
+
"api_key": token,
|
|
2543
|
+
"base_url": _COPILOT_BASE_URL,
|
|
2544
|
+
"max_retries": 0,
|
|
2545
|
+
"default_headers": _copilot_default_headers(),
|
|
2546
|
+
}
|
|
2547
|
+
return AsyncOpenAI(**kwargs), OpenAI(**kwargs)
|
|
2548
|
+
|
|
2549
|
+
|
|
2550
|
+
def _require_copilot_env(_: Path | None = None) -> None:
|
|
2551
|
+
_require_string(
|
|
2552
|
+
_get_github_token(),
|
|
2553
|
+
"No GitHub token found (set GH_TOKEN, GITHUB_TOKEN, or run `gh auth login`)",
|
|
2554
|
+
)
|
|
2555
|
+
|
|
2556
|
+
|
|
2557
|
+
def _fetch_copilot_models_raw(token: str) -> list[JSONDict]:
|
|
2558
|
+
"""Fetch the full model metadata list from the Copilot API."""
|
|
2559
|
+
resp = httpx.get(
|
|
2560
|
+
f"{_COPILOT_BASE_URL}/models",
|
|
2561
|
+
headers={
|
|
2562
|
+
"Authorization": f"Bearer {token}",
|
|
2563
|
+
**_copilot_default_headers(),
|
|
2564
|
+
},
|
|
2565
|
+
timeout=15,
|
|
2566
|
+
)
|
|
2567
|
+
resp.raise_for_status()
|
|
2568
|
+
data = resp.json()
|
|
2569
|
+
return data.get("data", []) if isinstance(data, dict) else []
|
|
2570
|
+
|
|
2571
|
+
|
|
2572
|
+
def _copilot_chat_model_ids(token: str) -> list[str]:
|
|
2573
|
+
"""Return sorted model IDs that support chat (not embeddings)."""
|
|
2574
|
+
raw = _fetch_copilot_models_raw(token)
|
|
2575
|
+
return sorted(
|
|
2576
|
+
m["id"]
|
|
2577
|
+
for m in raw
|
|
2578
|
+
if isinstance(m.get("id"), str)
|
|
2579
|
+
and m.get("capabilities", {}).get("type") == "chat"
|
|
2580
|
+
)
|
|
2581
|
+
|
|
2582
|
+
|
|
2583
|
+
def _copilot_responses_model_ids(token: str) -> set[str]:
|
|
2584
|
+
"""Return model IDs that support the /responses endpoint."""
|
|
2585
|
+
raw = _fetch_copilot_models_raw(token)
|
|
2586
|
+
return {
|
|
2587
|
+
m["id"]
|
|
2588
|
+
for m in raw
|
|
2589
|
+
if isinstance(m.get("id"), str)
|
|
2590
|
+
and "/responses" in (m.get("supported_endpoints") or [])
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
|
|
2594
|
+
def _build_copilot_client(
|
|
2595
|
+
region: str | None = None, cwd: Path | None = None
|
|
2596
|
+
) -> CompletionClient:
|
|
2597
|
+
"""Build a Copilot client that routes to /responses or /chat/completions."""
|
|
2598
|
+
_ = region, cwd
|
|
2599
|
+
token = _require_string(_get_github_token(), "No GitHub token found")
|
|
2600
|
+
async_client, sync_client = _copilot_openai_pair(token)
|
|
2601
|
+
|
|
2602
|
+
# Probe which models support /responses vs /chat/completions
|
|
2603
|
+
try:
|
|
2604
|
+
responses_models = _copilot_responses_model_ids(token)
|
|
2605
|
+
except Exception:
|
|
2606
|
+
responses_models = set()
|
|
2607
|
+
|
|
2608
|
+
responses_inner = _openai_responses_client(
|
|
2609
|
+
async_client, sync_client, fallback_models=None, default_models=None
|
|
2610
|
+
)
|
|
2611
|
+
chat_inner = _openai_chat_completions_client(
|
|
2612
|
+
async_client, sync_client, tools_map=_tool_specs_to_openai
|
|
2613
|
+
)
|
|
2614
|
+
|
|
2615
|
+
async def chat_completion(
|
|
2616
|
+
model: str,
|
|
2617
|
+
messages: list[ChatMessage],
|
|
2618
|
+
tools: list[ToolSpec] | None = None,
|
|
2619
|
+
tool_choice: str = "auto",
|
|
2620
|
+
on_retry=None,
|
|
2621
|
+
) -> AssistantMessage:
|
|
2622
|
+
inner = responses_inner if model in responses_models else chat_inner
|
|
2623
|
+
return await inner.chat_completion(model, messages, tools, tool_choice, on_retry)
|
|
2624
|
+
|
|
2625
|
+
def list_models() -> list[str]:
|
|
2626
|
+
try:
|
|
2627
|
+
return _copilot_chat_model_ids(token)
|
|
2628
|
+
except Exception:
|
|
2629
|
+
return sorted(
|
|
2630
|
+
m.id
|
|
2631
|
+
for m in sync_client.models.list()
|
|
2632
|
+
if not m.id.startswith("text-embedding")
|
|
2633
|
+
)
|
|
2634
|
+
|
|
2635
|
+
return CompletionClient(chat_completion=chat_completion, list_models=list_models)
|
|
2636
|
+
|
|
2637
|
+
|
|
2638
|
+
def _list_copilot_models(
|
|
2639
|
+
region: str | None = None, cwd: Path | None = None
|
|
2640
|
+
) -> list[str]:
|
|
2641
|
+
_ = region, cwd
|
|
2642
|
+
return _build_copilot_client(region, cwd).list_models()
|
|
2643
|
+
|
|
2644
|
+
|
|
2489
2645
|
SHIM_SPECS: dict[str, ShimSpec] = {
|
|
2490
2646
|
SHIM_OPENAI: ShimSpec(
|
|
2491
2647
|
name=SHIM_OPENAI,
|
|
@@ -2523,6 +2679,12 @@ SHIM_SPECS: dict[str, ShimSpec] = {
|
|
|
2523
2679
|
build_client=_build_claude_shim,
|
|
2524
2680
|
list_models=_list_claude_models,
|
|
2525
2681
|
),
|
|
2682
|
+
SHIM_COPILOT: ShimSpec(
|
|
2683
|
+
name=SHIM_COPILOT,
|
|
2684
|
+
ensure_env=_require_copilot_env,
|
|
2685
|
+
build_client=_build_copilot_client,
|
|
2686
|
+
list_models=_list_copilot_models,
|
|
2687
|
+
),
|
|
2526
2688
|
}
|
|
2527
2689
|
|
|
2528
2690
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|