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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oy-cli
3
- Version: 0.3.4
3
+ Version: 0.3.5
4
4
  Summary: Tiny local coding CLI with a small tool surface
5
5
  Author: oy-cli contributors
6
6
  License-Expression: Apache-2.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oy-cli
3
- Version: 0.3.4
3
+ Version: 0.3.5
4
4
  Summary: Tiny local coding CLI with a small tool surface
5
5
  Author: oy-cli contributors
6
6
  License-Expression: Apache-2.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
- """Read user input, supporting \\ continuation for multi-line."""
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
- if not line.endswith("\\"):
1805
- return line
1806
- parts = [line[:-1]]
1807
- while True:
1808
- cont = input("... ")
1809
- if not cont.endswith("\\"):
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
- break
1812
- parts.append(cont[:-1])
1813
- return "\n".join(parts)
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: end a line with `\\` to continue on the next line.",
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":
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "oy-cli"
7
- version = "0.3.4"
7
+ version = "0.3.5"
8
8
  description = "Tiny local coding CLI with a small tool surface"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
@@ -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