fix: docs/COMMAND-REGISTRY.json check fails on fresh Windows clone (missing .gitattributes) (#2437)

* fix: add .gitattributes to force LF line endings for text files

npm run command-registry:check (part of npm test) fails on a fresh clone
on Windows with the common core.autocrlf=true setting: git checks out
docs/COMMAND-REGISTRY.json with CRLF, but generate-command-registry.js
always writes LF, so the strict string comparison in checkRegistry()
never matches. Forcing LF via .gitattributes makes checkouts consistent
across platforms regardless of a contributor's local autocrlf setting.

* fix: normalize CRLF line endings to LF per .gitattributes

pyproject.toml, src/llm/__init__.py, src/llm/prompt/builder.py,
src/llm/providers/claude.py, and tests/test_builder.py had CRLF line
endings committed to the repo, inconsistent with the rest of the
codebase. Renormalized via 'git add --renormalize .' now that
.gitattributes enforces eol=lf.

---------

Co-authored-by: Affaan Mustafa <me@affaanmustafa.com>
This commit is contained in:
Boube 2026-07-03 23:14:55 -04:00 committed by GitHub
parent 3af4676e99
commit 1a747097f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 408 additions and 401 deletions

7
.gitattributes vendored Normal file
View file

@ -0,0 +1,7 @@
* text=auto eol=lf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary

View file

@ -1,79 +1,79 @@
[project] [project]
name = "llm-abstraction" name = "llm-abstraction"
version = "0.1.0" version = "0.1.0"
description = "Provider-agnostic LLM abstraction layer" description = "Provider-agnostic LLM abstraction layer"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
license = {text = "MIT"} license = {text = "MIT"}
authors = [ authors = [
{name = "Affaan Mustafa", email = "affaan@example.com"} {name = "Affaan Mustafa", email = "affaan@example.com"}
] ]
keywords = ["llm", "openai", "anthropic", "ollama", "ai"] keywords = ["llm", "openai", "anthropic", "ollama", "ai"]
classifiers = [ classifiers = [
"Development Status :: 3 - Alpha", "Development Status :: 3 - Alpha",
"Intended Audience :: Developers", "Intended Audience :: Developers",
"License :: OSI Approved :: MIT License", "License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
] ]
dependencies = [ dependencies = [
"anthropic>=0.111.0", "anthropic>=0.111.0",
"openai>=1.30.0", "openai>=1.30.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"pytest>=9.1.1", "pytest>=9.1.1",
"pytest-asyncio>=0.23", "pytest-asyncio>=0.23",
"pytest-cov>=7.1.0", "pytest-cov>=7.1.0",
"pytest-mock>=3.12", "pytest-mock>=3.12",
"ruff>=0.4", "ruff>=0.4",
"mypy>=2.1.0", "mypy>=2.1.0",
"pyyaml>=6.0", "pyyaml>=6.0",
] ]
[project.urls] [project.urls]
Homepage = "https://github.com/affaan-m/everything-claude-code" Homepage = "https://github.com/affaan-m/everything-claude-code"
Repository = "https://github.com/affaan-m/everything-claude-code" Repository = "https://github.com/affaan-m/everything-claude-code"
[project.scripts] [project.scripts]
llm-select = "llm.cli.selector:main" llm-select = "llm.cli.selector:main"
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["src/llm"] packages = ["src/llm"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
asyncio_mode = "auto" asyncio_mode = "auto"
filterwarnings = ["ignore::DeprecationWarning"] filterwarnings = ["ignore::DeprecationWarning"]
[tool.coverage.run] [tool.coverage.run]
source = ["src/llm"] source = ["src/llm"]
branch = true branch = true
[tool.coverage.report] [tool.coverage.report]
exclude_lines = [ exclude_lines = [
"pragma: no cover", "pragma: no cover",
"if TYPE_CHECKING:", "if TYPE_CHECKING:",
"raise NotImplementedError", "raise NotImplementedError",
] ]
[tool.ruff] [tool.ruff]
src-path = ["src"] src-path = ["src"]
target-version = "py311" target-version = "py311"
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP"] select = ["E", "F", "I", "N", "W", "UP"]
ignore = ["E501"] ignore = ["E501"]
[tool.mypy] [tool.mypy]
python_version = "3.11" python_version = "3.11"
src_paths = ["src"] src_paths = ["src"]
warn_return_any = true warn_return_any = true
warn_unused_ignores = true warn_unused_ignores = true

View file

@ -1,33 +1,33 @@
""" """
LLM Abstraction Layer LLM Abstraction Layer
Provider-agnostic interface for multiple LLM backends. Provider-agnostic interface for multiple LLM backends.
""" """
from llm.core.interface import LLMProvider from llm.core.interface import LLMProvider
from llm.core.types import LLMInput, LLMOutput, Message, ToolCall, ToolDefinition, ToolResult from llm.core.types import LLMInput, LLMOutput, Message, ToolCall, ToolDefinition, ToolResult
from llm.providers import get_provider from llm.providers import get_provider
from llm.tools import ToolExecutor, ToolRegistry from llm.tools import ToolExecutor, ToolRegistry
from llm.cli.selector import interactive_select from llm.cli.selector import interactive_select
__version__ = "0.1.0" __version__ = "0.1.0"
__all__ = ( __all__ = (
"LLMInput", "LLMInput",
"LLMOutput", "LLMOutput",
"LLMProvider", "LLMProvider",
"Message", "Message",
"ToolCall", "ToolCall",
"ToolDefinition", "ToolDefinition",
"ToolResult", "ToolResult",
"ToolExecutor", "ToolExecutor",
"ToolRegistry", "ToolRegistry",
"get_provider", "get_provider",
"interactive_select", "interactive_select",
) )
def gui() -> None: def gui() -> None:
from llm.cli.selector import main from llm.cli.selector import main
main() main()

View file

@ -1,24 +1,24 @@
"""Prompt builder for normalizing prompts across providers.""" """Prompt builder for normalizing prompts across providers."""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from llm.core.types import LLMInput, Message, Role, ToolDefinition from llm.core.types import LLMInput, Message, Role, ToolDefinition
from llm.providers.claude import ClaudeProvider from llm.providers.claude import ClaudeProvider
from llm.providers.openai import OpenAIProvider from llm.providers.openai import OpenAIProvider
from llm.providers.ollama import OllamaProvider from llm.providers.ollama import OllamaProvider
@dataclass @dataclass
class PromptConfig: class PromptConfig:
system_template: str | None = None system_template: str | None = None
user_template: str | None = None user_template: str | None = None
include_tools_in_system: bool = True include_tools_in_system: bool = True
tool_format: str = "native" tool_format: str = "native"
class PromptBuilder: class PromptBuilder:
def __init__( def __init__(
self, self,
@ -45,81 +45,81 @@ class PromptBuilder:
config = PromptConfig(**{key: value for key, value in overrides.items() if value is not None}) config = PromptConfig(**{key: value for key, value in overrides.items() if value is not None})
self.config = config self.config = config
def build(self, messages: list[Message], tools: list[ToolDefinition] | None = None) -> list[Message]: def build(self, messages: list[Message], tools: list[ToolDefinition] | None = None) -> list[Message]:
if not messages: if not messages:
return [] return []
result: list[Message] = [] result: list[Message] = []
system_parts: list[str] = [] system_parts: list[str] = []
if self.config.system_template: if self.config.system_template:
system_parts.append(self.config.system_template) system_parts.append(self.config.system_template)
if tools and self.config.include_tools_in_system: if tools and self.config.include_tools_in_system:
tools_desc = self._format_tools(tools) tools_desc = self._format_tools(tools)
system_parts.append(f"\n\n## Available Tools\n{tools_desc}") system_parts.append(f"\n\n## Available Tools\n{tools_desc}")
if messages[0].role == Role.SYSTEM: if messages[0].role == Role.SYSTEM:
system_parts.insert(0, messages[0].content) system_parts.insert(0, messages[0].content)
result.insert(0, Message(role=Role.SYSTEM, content="\n\n".join(system_parts))) result.insert(0, Message(role=Role.SYSTEM, content="\n\n".join(system_parts)))
result.extend(messages[1:]) result.extend(messages[1:])
else: else:
if system_parts: if system_parts:
result.insert(0, Message(role=Role.SYSTEM, content="\n\n".join(system_parts))) result.insert(0, Message(role=Role.SYSTEM, content="\n\n".join(system_parts)))
result.extend(messages) result.extend(messages)
return result return result
def _format_tools(self, tools: list[ToolDefinition]) -> str: def _format_tools(self, tools: list[ToolDefinition]) -> str:
lines = [] lines = []
for tool in tools: for tool in tools:
lines.append(f"### {tool.name}") lines.append(f"### {tool.name}")
lines.append(tool.description) lines.append(tool.description)
if tool.parameters: if tool.parameters:
lines.append("Parameters:") lines.append("Parameters:")
lines.append(self._format_parameters(tool.parameters)) lines.append(self._format_parameters(tool.parameters))
return "\n".join(lines) return "\n".join(lines)
def _format_parameters(self, params: dict[str, Any]) -> str: def _format_parameters(self, params: dict[str, Any]) -> str:
if "properties" not in params: if "properties" not in params:
return str(params) return str(params)
lines = [] lines = []
required = params.get("required", []) required = params.get("required", [])
for name, spec in params["properties"].items(): for name, spec in params["properties"].items():
prop_type = spec.get("type", "any") prop_type = spec.get("type", "any")
desc = spec.get("description", "") desc = spec.get("description", "")
required_mark = "(required)" if name in required else "(optional)" required_mark = "(required)" if name in required else "(optional)"
lines.append(f" - {name}: {prop_type} {required_mark} - {desc}") lines.append(f" - {name}: {prop_type} {required_mark} - {desc}")
return "\n".join(lines) if lines else str(params) return "\n".join(lines) if lines else str(params)
_PROVIDER_TEMPLATE_MAP: dict[str, dict[str, Any]] = { _PROVIDER_TEMPLATE_MAP: dict[str, dict[str, Any]] = {
"claude": { "claude": {
"include_tools_in_system": False, "include_tools_in_system": False,
"tool_format": "anthropic", "tool_format": "anthropic",
}, },
"openai": { "openai": {
"include_tools_in_system": False, "include_tools_in_system": False,
"tool_format": "openai", "tool_format": "openai",
}, },
"ollama": { "ollama": {
"include_tools_in_system": True, "include_tools_in_system": True,
"tool_format": "text", "tool_format": "text",
}, },
} }
def get_provider_builder(provider_name: str) -> PromptBuilder: def get_provider_builder(provider_name: str) -> PromptBuilder:
config_dict = _PROVIDER_TEMPLATE_MAP.get(provider_name.lower(), {}) config_dict = _PROVIDER_TEMPLATE_MAP.get(provider_name.lower(), {})
config = PromptConfig(**config_dict) config = PromptConfig(**config_dict)
return PromptBuilder(config) return PromptBuilder(config)
def adapt_messages_for_provider( def adapt_messages_for_provider(
messages: list[Message], messages: list[Message],
provider: str, provider: str,
tools: list[ToolDefinition] | None = None, tools: list[ToolDefinition] | None = None,
) -> list[Message]: ) -> list[Message]:
builder = get_provider_builder(provider) builder = get_provider_builder(provider)
return builder.build(messages, tools) return builder.build(messages, tools)

View file

@ -1,137 +1,137 @@
"""Claude provider adapter.""" """Claude provider adapter."""
from __future__ import annotations from __future__ import annotations
import os import os
from typing import Any from typing import Any
from anthropic import Anthropic from anthropic import Anthropic
from llm.core.interface import ( from llm.core.interface import (
AuthenticationError, AuthenticationError,
ContextLengthError, ContextLengthError,
LLMProvider, LLMProvider,
RateLimitError, RateLimitError,
) )
from llm.core.types import LLMInput, LLMOutput, ModelInfo, ProviderType, Role, ToolCall from llm.core.types import LLMInput, LLMOutput, ModelInfo, ProviderType, Role, ToolCall
_DEFAULT_MODEL = "claude-sonnet-4-6" _DEFAULT_MODEL = "claude-sonnet-4-6"
_OPUS_ADAPTIVE_ONLY_PREFIXES = ("claude-opus-4-7", "claude-opus-4-8") _OPUS_ADAPTIVE_ONLY_PREFIXES = ("claude-opus-4-7", "claude-opus-4-8")
def _uses_adaptive_thinking_only(model: str) -> bool: def _uses_adaptive_thinking_only(model: str) -> bool:
return any(model.startswith(prefix) for prefix in _OPUS_ADAPTIVE_ONLY_PREFIXES) return any(model.startswith(prefix) for prefix in _OPUS_ADAPTIVE_ONLY_PREFIXES)
class ClaudeProvider(LLMProvider): class ClaudeProvider(LLMProvider):
provider_type = ProviderType.CLAUDE provider_type = ProviderType.CLAUDE
def __init__(self, api_key: str | None = None, base_url: str | None = None) -> None: def __init__(self, api_key: str | None = None, base_url: str | None = None) -> None:
self.client = Anthropic(api_key=api_key or os.environ.get("ANTHROPIC_API_KEY"), base_url=base_url) self.client = Anthropic(api_key=api_key or os.environ.get("ANTHROPIC_API_KEY"), base_url=base_url)
self._models = [ self._models = [
ModelInfo( ModelInfo(
name="claude-opus-4-8", name="claude-opus-4-8",
provider=ProviderType.CLAUDE, provider=ProviderType.CLAUDE,
supports_tools=True, supports_tools=True,
supports_vision=True, supports_vision=True,
max_tokens=64000, max_tokens=64000,
context_window=1_000_000, context_window=1_000_000,
), ),
ModelInfo( ModelInfo(
name="claude-sonnet-4-6", name="claude-sonnet-4-6",
provider=ProviderType.CLAUDE, provider=ProviderType.CLAUDE,
supports_tools=True, supports_tools=True,
supports_vision=True, supports_vision=True,
max_tokens=64000, max_tokens=64000,
context_window=1_000_000, context_window=1_000_000,
), ),
ModelInfo( ModelInfo(
name="claude-haiku-4-5", name="claude-haiku-4-5",
provider=ProviderType.CLAUDE, provider=ProviderType.CLAUDE,
supports_tools=True, supports_tools=True,
supports_vision=True, supports_vision=True,
max_tokens=16000, max_tokens=16000,
context_window=200_000, context_window=200_000,
), ),
] ]
def generate(self, input: LLMInput) -> LLMOutput: def generate(self, input: LLMInput) -> LLMOutput:
try: try:
model = input.model or _DEFAULT_MODEL model = input.model or _DEFAULT_MODEL
system_parts = [msg.content for msg in input.messages if msg.role == Role.SYSTEM] system_parts = [msg.content for msg in input.messages if msg.role == Role.SYSTEM]
api_messages = [ api_messages = [
msg.to_dict() for msg in input.messages if msg.role not in (Role.SYSTEM,) msg.to_dict() for msg in input.messages if msg.role not in (Role.SYSTEM,)
] ]
params: dict[str, Any] = { params: dict[str, Any] = {
"model": model, "model": model,
"messages": api_messages, "messages": api_messages,
"max_tokens": input.max_tokens if input.max_tokens else 16000, "max_tokens": input.max_tokens if input.max_tokens else 16000,
"cache_control": {"type": "ephemeral"}, "cache_control": {"type": "ephemeral"},
} }
if system_parts: if system_parts:
params["system"] = "\n\n".join(system_parts) params["system"] = "\n\n".join(system_parts)
if input.tools: if input.tools:
params["tools"] = [tool.to_anthropic_tool() for tool in input.tools] params["tools"] = [tool.to_anthropic_tool() for tool in input.tools]
if not _uses_adaptive_thinking_only(model): if not _uses_adaptive_thinking_only(model):
params["temperature"] = input.temperature params["temperature"] = input.temperature
if _uses_adaptive_thinking_only(model): if _uses_adaptive_thinking_only(model):
params["thinking"] = {"type": "adaptive"} params["thinking"] = {"type": "adaptive"}
response = self.client.messages.create(**params) response = self.client.messages.create(**params)
text_parts: list[str] = [] text_parts: list[str] = []
tool_calls: list[ToolCall] = [] tool_calls: list[ToolCall] = []
for block in response.content or []: for block in response.content or []:
block_type = getattr(block, "type", None) block_type = getattr(block, "type", None)
if block_type == "text": if block_type == "text":
text = getattr(block, "text", "") text = getattr(block, "text", "")
if text: if text:
text_parts.append(text) text_parts.append(text)
elif block_type == "tool_use": elif block_type == "tool_use":
raw_arguments = getattr(block, "input", {}) raw_arguments = getattr(block, "input", {})
arguments = ( arguments = (
raw_arguments.copy() raw_arguments.copy()
if isinstance(raw_arguments, dict) if isinstance(raw_arguments, dict)
else getattr(raw_arguments, "__dict__", {}).copy() else getattr(raw_arguments, "__dict__", {}).copy()
) )
tool_calls.append( tool_calls.append(
ToolCall( ToolCall(
id=getattr(block, "id", ""), id=getattr(block, "id", ""),
name=getattr(block, "name", ""), name=getattr(block, "name", ""),
arguments=arguments, arguments=arguments,
) )
) )
return LLMOutput( return LLMOutput(
content="".join(text_parts), content="".join(text_parts),
tool_calls=tool_calls or None, tool_calls=tool_calls or None,
model=response.model, model=response.model,
usage={ usage={
"input_tokens": response.usage.input_tokens, "input_tokens": response.usage.input_tokens,
"output_tokens": response.usage.output_tokens, "output_tokens": response.usage.output_tokens,
"cache_creation_input_tokens": getattr( "cache_creation_input_tokens": getattr(
response.usage, "cache_creation_input_tokens", 0 response.usage, "cache_creation_input_tokens", 0
), ),
"cache_read_input_tokens": getattr(response.usage, "cache_read_input_tokens", 0), "cache_read_input_tokens": getattr(response.usage, "cache_read_input_tokens", 0),
}, },
stop_reason=response.stop_reason, stop_reason=response.stop_reason,
) )
except Exception as e: except Exception as e:
msg = str(e) msg = str(e)
if "401" in msg or "authentication" in msg.lower(): if "401" in msg or "authentication" in msg.lower():
raise AuthenticationError(msg, provider=ProviderType.CLAUDE) from e raise AuthenticationError(msg, provider=ProviderType.CLAUDE) from e
if "429" in msg or "rate_limit" in msg.lower(): if "429" in msg or "rate_limit" in msg.lower():
raise RateLimitError(msg, provider=ProviderType.CLAUDE) from e raise RateLimitError(msg, provider=ProviderType.CLAUDE) from e
if "context" in msg.lower() and "length" in msg.lower(): if "context" in msg.lower() and "length" in msg.lower():
raise ContextLengthError(msg, provider=ProviderType.CLAUDE) from e raise ContextLengthError(msg, provider=ProviderType.CLAUDE) from e
raise raise
def list_models(self) -> list[ModelInfo]: def list_models(self) -> list[ModelInfo]:
return self._models.copy() return self._models.copy()
def validate_config(self) -> bool: def validate_config(self) -> bool:
return bool(self.client.api_key) return bool(self.client.api_key)
def get_default_model(self) -> str: def get_default_model(self) -> str:
return _DEFAULT_MODEL return _DEFAULT_MODEL

View file

@ -1,29 +1,29 @@
import pytest import pytest
from llm.core.types import LLMInput, Message, Role, ToolDefinition from llm.core.types import LLMInput, Message, Role, ToolDefinition
from llm.prompt import PromptBuilder, adapt_messages_for_provider from llm.prompt import PromptBuilder, adapt_messages_for_provider
from llm.prompt.builder import PromptConfig from llm.prompt.builder import PromptConfig
class TestPromptBuilder: class TestPromptBuilder:
def test_build_without_system(self): def test_build_without_system(self):
messages = [Message(role=Role.USER, content="Hello")] messages = [Message(role=Role.USER, content="Hello")]
builder = PromptBuilder() builder = PromptBuilder()
result = builder.build(messages) result = builder.build(messages)
assert len(result) == 1 assert len(result) == 1
assert result[0].role == Role.USER assert result[0].role == Role.USER
def test_build_with_system(self): def test_build_with_system(self):
messages = [ messages = [
Message(role=Role.SYSTEM, content="You are helpful."), Message(role=Role.SYSTEM, content="You are helpful."),
Message(role=Role.USER, content="Hello"), Message(role=Role.USER, content="Hello"),
] ]
builder = PromptBuilder() builder = PromptBuilder()
result = builder.build(messages) result = builder.build(messages)
assert len(result) == 2 assert len(result) == 2
assert result[0].role == Role.SYSTEM assert result[0].role == Role.SYSTEM
def test_build_adds_system_from_keyword_options(self): def test_build_adds_system_from_keyword_options(self):
messages = [Message(role=Role.USER, content="Hello")] messages = [Message(role=Role.USER, content="Hello")]
builder = PromptBuilder(system_template="You are a pirate.") builder = PromptBuilder(system_template="You are a pirate.")
@ -55,30 +55,30 @@ class TestPromptBuilder:
assert result == messages assert result == messages
def test_build_with_tools(self): def test_build_with_tools(self):
messages = [Message(role=Role.USER, content="Search for something")] messages = [Message(role=Role.USER, content="Search for something")]
tools = [ tools = [
ToolDefinition(name="search", description="Search the web", parameters={}), ToolDefinition(name="search", description="Search the web", parameters={}),
] ]
builder = PromptBuilder(include_tools_in_system=True) builder = PromptBuilder(include_tools_in_system=True)
result = builder.build(messages, tools) result = builder.build(messages, tools)
assert len(result) == 2 assert len(result) == 2
assert "search" in result[0].content assert "search" in result[0].content
assert "Available Tools" in result[0].content assert "Available Tools" in result[0].content
class TestAdaptMessagesForProvider: class TestAdaptMessagesForProvider:
def test_adapt_for_claude(self): def test_adapt_for_claude(self):
messages = [Message(role=Role.USER, content="Hello")] messages = [Message(role=Role.USER, content="Hello")]
result = adapt_messages_for_provider(messages, "claude") result = adapt_messages_for_provider(messages, "claude")
assert len(result) == 1 assert len(result) == 1
def test_adapt_for_openai(self): def test_adapt_for_openai(self):
messages = [Message(role=Role.USER, content="Hello")] messages = [Message(role=Role.USER, content="Hello")]
result = adapt_messages_for_provider(messages, "openai") result = adapt_messages_for_provider(messages, "openai")
assert len(result) == 1 assert len(result) == 1
def test_adapt_for_ollama(self): def test_adapt_for_ollama(self):
messages = [Message(role=Role.USER, content="Hello")] messages = [Message(role=Role.USER, content="Hello")]
result = adapt_messages_for_provider(messages, "ollama") result = adapt_messages_for_provider(messages, "ollama")
assert len(result) == 1 assert len(result) == 1