From 741f773ee1c482e48e7579f10e92b4c53572022c Mon Sep 17 00:00:00 2001 From: Jeff Dupont Date: Tue, 20 Oct 2026 15:33:24 -0700 Subject: [PATCH 1/3] [AIC-3500] chore(AIC-3500): commit an API report for every public module Each public module (every package root, and each module of the core package's experimental subpackage) now has a committed report at packages//api-report/.txt, generated by scripts/api_report.py with griffe. A report lists each name in __all__ with its full signature, and a class's public members indented below it. tests/test_api_report.py fails while any report is out of date, so `make test` and CI catch a public name or signature change whose report wasn't regenerated. It also checks the generator against a fixture package. `make api-report` writes the reports, `make api-report-check` checks them, and .github/CODEOWNERS gives them to team-ai-agents. griffelib is now an explicit dev dependency. Co-Authored-By: Claude Opus 5.5 --- .github/CODEOWNERS | 2 + AGENTS.md | 8 + Makefile | 10 +- .../launchdarkly_ai_claude_agents.txt | 18 ++ .../launchdarkly_ai_claude_messages.txt | 3 + ...kly_ai_server.experimental.evaluations.txt | 72 ++++++ ...chdarkly_ai_server.experimental.skills.txt | 81 ++++++ .../api-report/launchdarkly_ai_server.txt | 236 ++++++++++++++++++ .../launchdarkly_ai_langchain_agents.txt | 5 + .../launchdarkly_ai_langchain_messages.txt | 3 + .../launchdarkly_ai_openai_agents.txt | 6 + .../launchdarkly_ai_openai_messages.txt | 3 + .../launchdarkly_ai_opencode_agents.txt | 4 + pyproject.toml | 1 + scripts/api_report.py | 184 ++++++++++++++ tests/test_api_report.py | 141 +++++++++++ uv.lock | 1 + 17 files changed, 777 insertions(+), 1 deletion(-) create mode 100644 .github/CODEOWNERS create mode 100644 packages/claude-agents/api-report/launchdarkly_ai_claude_agents.txt create mode 100644 packages/claude-messages/api-report/launchdarkly_ai_claude_messages.txt create mode 100644 packages/client/api-report/launchdarkly_ai_server.experimental.evaluations.txt create mode 100644 packages/client/api-report/launchdarkly_ai_server.experimental.skills.txt create mode 100644 packages/client/api-report/launchdarkly_ai_server.txt create mode 100644 packages/langchain-agents/api-report/launchdarkly_ai_langchain_agents.txt create mode 100644 packages/langchain-messages/api-report/launchdarkly_ai_langchain_messages.txt create mode 100644 packages/openai-agents/api-report/launchdarkly_ai_openai_agents.txt create mode 100644 packages/openai-messages/api-report/launchdarkly_ai_openai_messages.txt create mode 100644 packages/opencode-agents/api-report/launchdarkly_ai_opencode_agents.txt create mode 100644 scripts/api_report.py create mode 100644 tests/test_api_report.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..9dc72f1d --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# The API reports are the public API contract: a change to one changes a public name or signature. +/packages/*/api-report/ @launchdarkly/team-ai-agents diff --git a/AGENTS.md b/AGENTS.md index d022c77f..661c7b7d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,6 +74,14 @@ them mypy-clean under `strict` is a separate piece of work. The `UP046` rule (PEP 695 `type` statement syntax for generic classes) is **ignored** in `pyproject.toml`. Do not convert `class Foo(Generic[T]):` to PEP 695 syntax — it is a deliberate migration decision. +## Public API Reports + +Every public module has a committed API report at `packages//api-report/.txt`: each name in its `__all__`, one per line, with its full signature, and a class's public members indented below it. The reports are the public API contract. + +- **Change a public name or signature, then run `make api-report`** and commit the updated report in the same PR. `make test` fails while a report is out of date (`tests/test_api_report.py`), and so does `make api-report-check`. +- **The reports are owned by `@launchdarkly/team-ai-agents`** in `.github/CODEOWNERS`, so their approval of the report diff is how public API changes are reviewed. +- **Never edit a report by hand.** It is generated by `scripts/api_report.py` with griffe. + ## PR Titles and the Changelog PRs are squash-merged with the PR title as the commit message, and release-please builds each package's changelog from those messages. diff --git a/Makefile b/Makefile index 71630ad9..82de8e77 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: start test typecheck lint format format-check lint-fix build clean +.PHONY: start test typecheck lint format format-check lint-fix build clean api-report api-report-check # Run main.py (uv run python main.py) start: @@ -17,6 +17,14 @@ test: typecheck: uv run mypy packages/*/src +# Write every package's API report (packages/*/api-report/); commit the result +api-report: + uv run python scripts/api_report.py + +# Fail if any API report is out of date (also run by `make test`) +api-report-check: + uv run python scripts/api_report.py --check + # Check for lint errors lint: uv run ruff check . diff --git a/packages/claude-agents/api-report/launchdarkly_ai_claude_agents.txt b/packages/claude-agents/api-report/launchdarkly_ai_claude_agents.txt new file mode 100644 index 00000000..f7a24162 --- /dev/null +++ b/packages/claude-agents/api-report/launchdarkly_ai_claude_agents.txt @@ -0,0 +1,18 @@ +# Public API of launchdarkly_ai_claude_agents. Generated by `make api-report`; do not edit. +build_prompt(config: AiConfigRep, user_input: str | None, variables: dict[str, Any], history: list[dict[str, Any]] | None = None) -> tuple[str, str | None] +build_tool_mcp(config_tools: dict[str, Any], handlers: dict[str, Any]) -> Any [async] +claude_agents(config_key: str, user_input: str, context: LDContext, **kwargs: Any) -> Any +claude_graph(key: str, **options: Any) -> Any +ClaudeBash = NativeTool('Bash') +ClaudeEdit = NativeTool('Edit') +ClaudeGlob = NativeTool('Glob') +ClaudeGrep = NativeTool('Grep') +ClaudeNotebookEdit = NativeTool('NotebookEdit') +ClaudeRead = NativeTool('Read') +ClaudeTodoWrite = NativeTool('TodoWrite') +ClaudeWebFetch = NativeTool('WebFetch') +ClaudeWebSearch = NativeTool('WebSearch') +ClaudeWrite = NativeTool('Write') +create_claude_agents_handler(*, capture_content: bool | None = None) -> ProviderHandler +partition_tools(config_tools: dict[str, Any] | None, tool_handlers: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any], list[str]] +to_claude_agents(def_promise: Any, opts: dict[str, Any] | None = None) -> Any diff --git a/packages/claude-messages/api-report/launchdarkly_ai_claude_messages.txt b/packages/claude-messages/api-report/launchdarkly_ai_claude_messages.txt new file mode 100644 index 00000000..b051ead8 --- /dev/null +++ b/packages/claude-messages/api-report/launchdarkly_ai_claude_messages.txt @@ -0,0 +1,3 @@ +# Public API of launchdarkly_ai_claude_messages. Generated by `make api-report`; do not edit. +claude_messages(config_key: str, user_input: str, context: LDContext, **kwargs: Any) -> Any +create_claude_messages_handler(*, capture_content: bool | None = None) -> ProviderHandler diff --git a/packages/client/api-report/launchdarkly_ai_server.experimental.evaluations.txt b/packages/client/api-report/launchdarkly_ai_server.experimental.evaluations.txt new file mode 100644 index 00000000..b4abf15e --- /dev/null +++ b/packages/client/api-report/launchdarkly_ai_server.experimental.evaluations.txt @@ -0,0 +1,72 @@ +# Public API of launchdarkly_ai_server.experimental.evaluations. Generated by `make api-report`; do not edit. +AIConfig [class, @dataclass(frozen=True)] + __init__(key: str, variation: str) + key: str + variation: str +Criterion = Judge | Scorer [type] +DatasetRow [class, @dataclass] + __init__(row_index: int, input: str | None = None, expected_output: str | None = None, variables: dict[str, Any] = dict(), metadata: dict[str, Any] | None = None) + expected_output: str | None = None + input: str | None = None + metadata: dict[str, Any] | None = None + row_index: int + variables: dict[str, Any] = field(default_factory=dict) +EvalRunResult [class, @dataclass] + __init__(passed: bool, url: str, run_id: str, summary: RunSummary) + passed: bool + run_id: str + summary: RunSummary + url: str +EvalTool [class, @dataclass(frozen=True)] + __init__(key: str, implementation: ToolImplementation, schema: dict[str, Any], description: str = '') + description: str = '' + implementation: ToolImplementation + key: str + project_key: str | None = field(default=None, init=False) + schema: dict[str, Any] + source: Literal['library', 'inline'] = field(default='inline', init=False) + to_create_wire() -> dict[str, Any] + version: int | None = field(default=None, init=False) +EvaluationsError(Exception) [class] +EvaluationsModule [class] + __init__(api_client: LDApiClient, project_key: str, sdk_key: str | None, ui_base_uri: str = DEFAULT_UI_BASE_URI) + api: LDApiClient [property] + project_key: str [property] + run(*, key: str, dataset: str | Sequence[InlineDatasetRow], handler: EvalHandler, generation: GenerationConfig | None = None, ai_config: AIConfig | None = None, tools: Sequence[EvalTool] | None = None, criteria: list[Criterion] | None = None, judge_handlers: list[EvalHandler] | None = None, concurrency: int = 10, poll_interval_seconds: float | None = None, poll_timeout_seconds: float | None = None) -> EvalRunResult [async] + sdk_key: str | None [property] + tools: ToolsClient [property] + ui_base_uri: str [property] +GenerationConfig(TypedDict) [class] + instructions: str + messages: list[dict[str, Any]] + model: str + output_format: dict[str, Any] + parameters: dict[str, Any] + prompt_snippets: dict[str, str] + provider: str +init_evaluations(project_key: str | None = None, api_key: str | None = None, sdk_key: str | None = None, base_uri: str | None = None, ui_base_uri: str | None = None, transport: Transport = urllib_transport) -> EvaluationsModule +Judge [class, @dataclass(frozen=True)] + __init__(key: str, threshold: float | None = DEFAULT_JUDGE_THRESHOLD, pass_rate_threshold: float | None = None, ground_truth_context: str | None = None) + criterion_type: str [property] + ground_truth_context: str | None = None + key: str + pass_rate_threshold: float | None = None + threshold: float | None = DEFAULT_JUDGE_THRESHOLD + to_criteria_wire() -> dict[str, Any] +RunSummary [class, @dataclass] + __init__(total_rows: int = 0, passed_rows: int = 0, failed_rows: int = 0, error_rows: int = 0, pending_rows: int = 0) + error_rows: int = 0 + failed_rows: int = 0 + from_wire(data: Mapping[str, Any] | None) -> RunSummary [classmethod] + passed_rows: int = 0 + pending_rows: int = 0 + total_rows: int = 0 +Scorer [class, @dataclass(frozen=True)] + __init__(name: str, fn: ScorerFn, threshold: float | None = 1.0, pass_rate_threshold: float | None = None, success_direction: SuccessDirection = 'higher_is_better') + criterion_type: str [property] + fn: ScorerFn + name: str + pass_rate_threshold: float | None = None + success_direction: SuccessDirection = 'higher_is_better' + threshold: float | None = 1.0 + to_criteria_wire() -> dict[str, Any] diff --git a/packages/client/api-report/launchdarkly_ai_server.experimental.skills.txt b/packages/client/api-report/launchdarkly_ai_server.experimental.skills.txt new file mode 100644 index 00000000..18639cd0 --- /dev/null +++ b/packages/client/api-report/launchdarkly_ai_server.experimental.skills.txt @@ -0,0 +1,81 @@ +# Public API of launchdarkly_ai_server.experimental.skills. Generated by `make api-report`; do not edit. +all_skills() -> list[Skill] [async] +FDv2SkillStore [class] + __init__(sdk_key: str, *, base_uri: str | None = None, stream_uri: str | None = None, mode: Mode = 'stream', poll_interval: float = 30.0, read_timeout: float | None = None, initial_backoff: float = 1.0, max_backoff: float = 30.0, _requester: Any = None) + add_listener(kind: str, fn: Callable[[dict[str, Any]], Any]) -> None + all_objects(kind: str) -> dict[str, dict[str, Any]] + close(timeout: float = 5.0) -> None + diagnostics: StoreDiagnostics [property] + failed: str | None [property] + get_object(kind: str, key: str, version: int | None = None) -> dict[str, Any] | None + is_initialized() -> bool + remove_listener(kind: str, fn: Callable[[dict[str, Any]], Any]) -> None + start() -> FDv2SkillStore + wait_for_skills(timeout: float = 10.0) -> bool +get_skill(key: str, *, version: int | None = None) -> Skill | None [async] +get_skill_result(key: str, *, version: int | None = None) -> SkillOutcome [async] +get_skills(refs: Sequence[SkillReference | str]) -> list[Skill] [async] +InMemorySkillStore [class] + __init__(objects: dict[str, dict[str, Any]] | None = None) + add_listener(kind: str, fn: Callable[[dict[str, Any]], Any]) -> None + all_objects(kind: str) -> dict[str, dict[str, Any]] + get_object(kind: str, key: str, version: int | None = None) -> dict[str, Any] | None + put(raw: dict[str, Any]) -> None + remove_listener(kind: str, fn: Callable[[dict[str, Any]], Any]) -> None +MANIFEST_FILENAME = '.launchdarkly-skills.json' +MANIFEST_VERSION = 1 +OnUnavailable = Literal['keep', 'raise'] +ReconcileAction [class, @dataclass(frozen=True)] + __init__(key: str, action: ReconcileActionKind, version: int | None = None, path: str | None = None, error: str | None = None) + action: ReconcileActionKind + error: str | None = None + key: str + path: str | None = None + version: int | None = None +ReconcileActionKind = Literal['written', 'updated', 'skipped_current', 'removed', 'error'] +ReconcileReport [class, @dataclass(frozen=True)] + __init__(actions: list[ReconcileAction] = list()) + actions: list[ReconcileAction] = field(default_factory=list) + errors: list[ReconcileAction] [property] + ok: bool [property] +set_skill_store(store: SkillStore | None) -> None +Skill [class, @dataclass(frozen=True)] + __init__(key: str, version: int, content: bytes, content_hash: str, name: str | None = None, description: str | None = None) + content: bytes + content_hash: str + description: str | None = None + key: str + name: str | None = None + version: int +SKILL_FILENAME = 'SKILL.md' +skill_refs(config: AiConfigRep | None) -> list[SkillReference] +SkillOutcome [class, @dataclass(frozen=True)] + __init__(skill: Skill | None, reason: SkillOutcomeReason, detail: str | None) + detail: str | None + reason: SkillOutcomeReason + skill: Skill | None +SkillOutcomeReason = Literal['absent', 'integrity_failure', 'ok', 'store_unavailable', 'wrong_version'] +SkillReference [class, @dataclass(frozen=True)] + __init__(key: str, version: int) + key: str + version: int +SkillStore(Protocol) [class] + all_objects(kind: str) -> dict[str, dict[str, Any]] + get_object(kind: str, key: str, version: int | None = None) -> dict[str, Any] | None +SkillWatcher [class] + __init__(request: Sequence[Skill | SkillReference | str] | str, root: str | os.PathLike[str], store: Any, *, prune: bool, timeout: float, on_unavailable: OnUnavailable, debounce: float, on_reconcile: Callable[[ReconcileReport], Any] | None) + close(timeout: float = 15.0) -> None + notify(_raw: Any = None) -> None + reconciles: int [property] +StoreDiagnostics [class, @dataclass] + __init__(payloads_transferred: int = 0, skill_objects_received: int = 0, objects_ignored: int = 0, objects_revoked: int = 0, payloads_ignored: int = 0, hashless_objects: int = 0, connection_failures: int = 0, last_error: str | None = None) + connection_failures: int = 0 + hashless_objects: int = 0 + last_error: str | None = None + objects_ignored: int = 0 + objects_revoked: int = 0 + payloads_ignored: int = 0 + payloads_transferred: int = 0 + skill_objects_received: int = 0 +watch_skills(skills: Sequence[Skill | SkillReference | str] | str, root: str | os.PathLike[str], *, prune: bool = True, timeout: float = 10.0, on_unavailable: OnUnavailable = 'keep', debounce: float = DEFAULT_DEBOUNCE_SECONDS, on_reconcile: Callable[[ReconcileReport], Any] | None = None) -> tuple[ReconcileReport, SkillWatcher] [async] +write_skills(skills: Sequence[Skill | SkillReference | str] | str, root: str | os.PathLike[str], *, prune: bool = True, timeout: float = 10.0, on_unavailable: OnUnavailable = 'keep') -> ReconcileReport [async] diff --git a/packages/client/api-report/launchdarkly_ai_server.txt b/packages/client/api-report/launchdarkly_ai_server.txt new file mode 100644 index 00000000..ecc9ea06 --- /dev/null +++ b/packages/client/api-report/launchdarkly_ai_server.txt @@ -0,0 +1,236 @@ +# Public API of launchdarkly_ai_server. Generated by `make api-report`; do not edit. +add_cached_tokens_to_input(raw_usage: dict[str, Any]) -> SpanUsage +AiConfigRep = dict[str, Any] +any_multimodal(turns: list[dict[str, Any]]) -> bool +build_judge_tasks(*, config: AiConfigRep, user_context: LDContext, handler: ProviderHandler, handlers: list[ProviderHandler] | None = None, llm_response: str, base_track_data: TrackData, user_input: str | None = None, trajectory: str = '') -> list[JudgeTask] [async] +compose(a: Registry, b: Registry) -> Registry +compose_history(*, history: list[dict[str, Any]], user_input: str | None = None, config_messages: list[dict[str, Any]] | None = None) -> list[dict[str, Any]] +config(*, key: str, handler: ProviderHandler | list[ProviderHandler] | None = None, tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, registry: Any = None, skip_judges: bool = False) -> ConfigInstance +ConfigInstance [class] + __init__(key: str, handler: ProviderHandler | list[ProviderHandler] | None, tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None, registry: Any, skip_judges: bool = False) + invoke(user_input: str | None, context: LDContext, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None) -> ProviderResponse[Any] [async] + stream(user_input: str | None, context: LDContext, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None) -> AsyncGenerator[StreamEvent, None] +configure(*, options: SDKOptions) -> None +content_to_text(content: MessageContent) -> str +conversation_id(conversation: str | None) -> Iterator[None] [@contextmanager] +ConversationIdSpanProcessor(_SpanProcessorBase) [class] + force_flush(timeout_millis: int | None = None) -> bool + on_end(span: Any) -> None + on_start(span: Span, parent_context: otel_context.Context | None = None) -> None + shutdown() -> None +create_handler(provides_for: tuple[str, Literal['agent', 'messages']], fn: _HandlerFn, stream_fn: _StreamFn | None = None, capture_content: bool | None = None) -> ProviderHandler +create_run_usage() -> RunUsage +end_span_once(span: Any, tracker: set[int], abandoned: bool = False, cancelled: bool = False) -> None +end_unfinished_spans(*spans: Any) -> None +execute_and_stream(*, config_key: str, config: AiConfigRep, meta: VariationMeta, user_context: LDContext, handler: ProviderHandler, user_input: str | None = None, tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, graph_key: str | None = None, history: list[dict[str, Any]] | None = None) -> AsyncGenerator[ExecuteStreamEvent, None] [async] +execute_and_track(*, config_key: str, config: AiConfigRep, meta: VariationMeta, user_context: LDContext, handler: ProviderHandler, user_input: str | None = None, tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, variables: dict[str, Any] | None = None, graph_key: str | None = None, history: list[dict[str, Any]] | None = None) -> dict[str, Any] [async] +ExecuteStreamDoneEvent = dict[str, Any] +ExecuteStreamEvent = dict[str, Any] +extract_variation(config_key: str, context: dict[str, Any]) -> dict[str, Any] [async] +get_client() -> Any +global_registry = Registry() +graph(key: str, *, handlers: list[ProviderHandler] | None = None, tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, registry: Any = None, graph_judge: str | None = None) -> GraphInstance +GraphArgs = dict[str, Any] +GraphDefinition [class] + __init__(*, key: str, enabled: bool, root: GraphNode | None, get_node: Callable[[str], GraphNode | None], get_child_nodes: Callable[[str], list[GraphNode]], get_parent_nodes: Callable[[str], list[GraphNode]], terminal_nodes: Callable[[], list[GraphNode]], is_terminal: Callable[[str], bool], edges_from: Callable[[str], list[GraphEdge]], run_node: Callable[..., Any], route: Callable[..., Any], traverse: Callable[..., Any], reverse_traverse: Callable[..., Any]) + edges_from = edges_from + enabled = enabled + get_child_nodes = get_child_nodes + get_node = get_node + get_parent_nodes = get_parent_nodes + is_terminal = is_terminal + key = key + reverse_traverse = reverse_traverse + root = root + route = route + run_node = run_node + terminal_nodes = terminal_nodes + traverse = traverse +GraphEdge [class, @dataclass] + __init__(key: str, source_key: str, target_key: str, handoff: dict[str, Any] | None = None) + handoff: dict[str, Any] | None = None + key: str + source_key: str + target_key: str +GraphInstance [class] + __init__(key: str, options: dict[str, Any]) + invoke(user_input: str | None, context: LDContext, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None) -> ProviderGraphResponse [async] + stream(user_input: str | None, context: LDContext, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None) -> AsyncGenerator[GraphStreamEvent, None] +GraphNode [class, @dataclass] + __init__(key: str, config: AiConfigRep, meta: VariationMeta, edges: list[GraphEdge] = list(), is_terminal: bool = False) + config: AiConfigRep + edges: list[GraphEdge] = field(default_factory=list) + is_terminal: bool = False + key: str + meta: VariationMeta +GraphOptions = dict[str, Any] +GraphStreamEvent = dict[str, Any] +GraphTopology = dict[str, Any] +HandlerResult = dict[str, Any] +HandlerStreamEvent [class] +has_multimodal_content(content: MessageContent) -> bool +image_block_to_url(block: dict[str, Any]) -> str +init_client(options: InitClientOptions | None = None, client: Any = None) -> Any [async] +InitClientOptions = dict[str, Any] +InputTokenDetails [class, @dataclass] + __init__(uncached: int = 0, cache_read: int = 0, cache_creation: int = 0) + cache_creation: int = 0 + cache_read: int = 0 + uncached: int = 0 +inspect_config(config_key: str, context: dict[str, Any]) -> dict[str, Any] [async] +is_content_blocks(content: MessageContent) -> bool +JudgeResult [class, @dataclass] + __init__(usage: UsageDict, response: str, score: float) + response: str + score: float + usage: UsageDict +JudgeRunResult [class, @dataclass] + __init__(score: float, response: str, usage: UsageDict, track_data: TrackData) + response: str + score: float + track_data: TrackData + usage: UsageDict +JudgeTask [class, @dataclass] + __init__(config_key: str, judge_config: AiConfigRep, judge_meta: VariationMeta, actual_output: str, user_context: LDContext, judge_provider: str | None, judge_mode: str, collapse_messages: bool, parent_track_data: TrackData, variables: dict[str, Any] | None = None, evaluation_metric_key: str | None = None, user_input: str | None = None, trajectory: str = '') + actual_output: str + collapse_messages: bool + config_key: str + evaluation_metric_key: str | None = None + judge_config: AiConfigRep + judge_meta: VariationMeta + judge_mode: str + judge_provider: str | None + parent_track_data: TrackData + trajectory: str = '' + user_context: LDContext + user_input: str | None = None + variables: dict[str, Any] | None = None +lang_chain_content_text(content: Any) -> str +lang_chain_finish_reasons(source: Any) -> list[str] | None +lang_chain_span_messages(messages: list[Any]) -> tuple[str | None, list[SpanMessage]] +lang_chain_span_usage(usage: dict[str, Any] | None) -> SpanUsage | None +LDClientInterface [class] + close() -> None [async] + flush() -> None [async] + track(event_name: str, context: LDContext, data: Any = None, metric_value: float | None = None) -> None + variation(key: str, context: LDContext, default_value: Any) -> Any [async] +LDContext = dict[str, Any] +make_track_data(node: GraphNode, graph_key: str, run_id: str) -> dict[str, Any] +Message [class] + content: str + role: Literal['user', 'assistant', 'system'] +model_stamps_from_meta(meta: Any) -> dict[str, Any] +NATIVE_TOOL_KEY: str = '__ld_native_tool__' +NativeTool [class] + __init__(tool_name: str) + id: object = object() + tool_name: str = tool_name +normalize_mode(mode: str | None) -> Literal['agent', 'messages'] +number_or_zero(value: Any) -> int +omit_model_stamps(track_data: Mapping[str, Any]) -> dict[str, Any] +parse_ai_config(raw: Any) -> ParseResult +parse_json_with_possible_fences(raw_text: str) -> Any | None +parse_template(template: str, variables: dict[str, Any]) -> str +parse_usage(usage: dict[str, Any]) -> dict[str, Any] +ParseFailure [class, @dataclass] + __init__(success: Literal[False], error: dict[str, str]) + error: dict[str, str] + success: Literal[False] +ParseResult = ParseSuccess[Any] | ParseFailure +ParseSuccess(Generic[T]) [class, @dataclass] + __init__(success: Literal[True], data: T) + data: T + success: Literal[True] +ProviderGraphResponse [class, @dataclass] + __init__(response: str, usage: UsageDict, judge_results: dict[str, JudgeResult] | None = None) + judge_results: dict[str, JudgeResult] | None = None + response: str + usage: UsageDict +ProviderHandler [class] + __init__(fn: _HandlerFn, provides_for: tuple[str, Literal['agent', 'messages']] | None = None, stream_fn: _StreamFn | None = None, capture_content: bool | None = None) + capture_content: bool [property] + has_stream: bool [property] + provides_for: tuple[str, Literal['agent', 'messages']] | None = provides_for + stream(config: AiConfigRep, user_input: str | None = None, tool_handlers: dict[str, Any] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None) -> AsyncGenerator[dict[str, Any], None] [async] +ProviderResponse(Generic[T]) [class, @dataclass] + __init__(response: T, usage: UsageDict, judge_results: dict[str, JudgeResult] | None = None, judge_tasks: list[JudgeTask] | None = None, track_data: TrackData | None = None) + judge_results: dict[str, JudgeResult] | None = None + judge_tasks: list[JudgeTask] | None = None + response: T + track_data: TrackData | None = None + usage: UsageDict +register_ai_sdk_package(name: str, version: str) -> None +Registry [class] + __init__(*, handlers: list[ProviderHandler] | None = None, tools: dict[str, Callable[..., Any] | NativeTool] | None = None) + handlers: list[ProviderHandler] [property] + register(*, handlers: list[ProviderHandler] | None = None, tools: dict[str, Callable[..., Any] | NativeTool] | None = None) -> None + tools: dict[str, Callable[..., Any] | NativeTool] [property] +report_usage(helper: str, package_name: str | None = None, package_version: str | None = None) -> None +resolve_graph(key: str, *, context: LDContext, handlers: list[ProviderHandler] | None = None, tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, registry: Any = None) -> GraphDefinition [async] +resolve_handlers(registry: Registry | None, local_handlers: list[ProviderHandler] | None) -> list[ProviderHandler] | None +resolve_tools(registry: Registry | None, local_tools: dict[str, Callable[..., Any] | NativeTool] | None) -> dict[str, Callable[..., Any] | NativeTool] | None +run_judge(task: JudgeTask, handlers: list[ProviderHandler]) -> JudgeRunResult | None [async] +run_judges(*, config: AiConfigRep, user_context: LDContext, handler: ProviderHandler, handlers: list[ProviderHandler] | None = None, user_input: str | None, llm_response: str, base_track_data: TrackData, tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, graph_key: str | None = None, trajectory: str = '') -> dict[str, JudgeResult] [async] +RunUsage [class, @dataclass] + __init__(total: SpanUsage = SpanUsage(), _turns: int = 0) + add(turn: SpanUsage | None) -> None + reported: bool [property] + total: SpanUsage = field(default_factory=SpanUsage) +SDK_INFO_CONTEXT: LDContext = {'kind': 'ld_ai', 'key': 'ld-internal-tracking', 'anonymous': True} +SDK_INFO_EVENT = '$ld:ai:sdk:info' +SDKOptions [class, @dataclass(frozen=True)] + __init__(capture_content: bool = False) + capture_content: bool = False +set_conversation_id_if_absent(span: Any, conversation: str) -> None +set_input_content_attributes(span: Any, capture: bool, *, system_instructions: str | None = None, messages: list[SpanMessage] | None = None, tool_definitions: list[ToolDefinitionInput] | None = None) -> None +set_ld_span_attributes(span: Any, variables: dict[str, Any] | None) -> None +set_model_identity_attributes(span: Any, provider_name: str, request_model: str, legacy_system: str | None = None) -> None +set_output_content_attributes(span: Any, capture: bool, messages: list[SpanMessage]) -> None +set_tool_call_content_attributes(span: Any, capture: bool, *, arguments: Any = None, result: Any = None) -> None +set_tool_definition_attributes(span: Any, capture: bool, tools: list[ToolDefinitionInput]) -> None +set_usage_span_attributes(span: Any, usage: SpanUsage) -> None +shutdown() -> None [async] +SpanMessage [class, @dataclass] + __init__(role: str, parts: list[SpanMessagePart] = list(), finish_reason: str | None = None) + finish_reason: str | None = None + parts: list[SpanMessagePart] = field(default_factory=list) + role: str + to_canonical() -> dict[str, Any] + to_text() -> str +SpanMessagePart [class, @dataclass] + __init__(type: Literal['text', 'reasoning', 'tool_call', 'tool_call_response'], content: str | None = None, id: str | None = None, name: str | None = None, arguments: Any = None, result: Any = None) + arguments: Any = None + content: str | None = None + id: str | None = None + name: str | None = None + result: Any = None + to_canonical() -> dict[str, Any] + to_text() -> str + type: Literal['text', 'reasoning', 'tool_call', 'tool_call_response'] +SpanUsage [class, @dataclass] + __init__(input: int = 0, output: int = 0, cache_read: int = 0, cache_creation: int = 0) + cache_creation: int = 0 + cache_read: int = 0 + input: int = 0 + output: int = 0 +StreamChunkEvent = dict[str, Any] +StreamDoneEvent = dict[str, Any] +StreamEvent = dict[str, Any] +text_message(role: str, content: str) -> SpanMessage +to_ld_context(client: Any, context: dict[str, Any]) -> Any +to_semconv_finish_reason(raw: str | None) -> str | None +to_usage_dict(usage: dict[str, Any]) -> UsageDict +ToolDefinitionInput [class, @dataclass] + __init__(name: str, description: str | None = None, parameters: Any = None) + description: str | None = None + name: str + parameters: Any = None +TrackData = dict[str, Any] +UsageDict [class, @dataclass] + __init__(input: int = 0, output: int = 0, total: int = 0, input_details: InputTokenDetails | None = None) + input: int = 0 + input_details: InputTokenDetails | None = None + output: int = 0 + total: int = 0 +VariationMeta = dict[str, Any] +wrap_tool_handlers(tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None, user_context: LDContext, track_data: TrackData) -> dict[str, Callable[..., Any]] diff --git a/packages/langchain-agents/api-report/launchdarkly_ai_langchain_agents.txt b/packages/langchain-agents/api-report/launchdarkly_ai_langchain_agents.txt new file mode 100644 index 00000000..4a23ab21 --- /dev/null +++ b/packages/langchain-agents/api-report/launchdarkly_ai_langchain_agents.txt @@ -0,0 +1,5 @@ +# Public API of launchdarkly_ai_langchain_agents. Generated by `make api-report`; do not edit. +create_langchain_agents_handler(llm: Any = None, *, capture_content: bool | None = None) -> ProviderHandler +langchain_agents(config_key: str, user_input: str, context: LDContext, **kwargs: Any) -> Any +langchain_graph(key: str, llm: Any = None, **options: Any) -> Any +to_lang_graph(def_promise: Any, opts: dict[str, Any] | None = None) -> Any diff --git a/packages/langchain-messages/api-report/launchdarkly_ai_langchain_messages.txt b/packages/langchain-messages/api-report/launchdarkly_ai_langchain_messages.txt new file mode 100644 index 00000000..c9602d48 --- /dev/null +++ b/packages/langchain-messages/api-report/launchdarkly_ai_langchain_messages.txt @@ -0,0 +1,3 @@ +# Public API of launchdarkly_ai_langchain_messages. Generated by `make api-report`; do not edit. +create_langchain_messages_handler(llm: Any = None, *, capture_content: bool | None = None) -> ProviderHandler +langchain_messages(config_key: str, user_input: str, context: LDContext, llm: Any = None, **kwargs: Any) -> Any diff --git a/packages/openai-agents/api-report/launchdarkly_ai_openai_agents.txt b/packages/openai-agents/api-report/launchdarkly_ai_openai_agents.txt new file mode 100644 index 00000000..6b977819 --- /dev/null +++ b/packages/openai-agents/api-report/launchdarkly_ai_openai_agents.txt @@ -0,0 +1,6 @@ +# Public API of launchdarkly_ai_openai_agents. Generated by `make api-report`; do not edit. +build_output_type(output_format: dict[str, Any] | None) -> dict[str, Any] | None +create_openai_agent_handler(*, capture_content: bool | None = None) -> ProviderHandler +openai_agents(config_key: str, user_input: str, context: LDContext, **kwargs: Any) -> Any +openai_graph(key: str, **options: Any) -> Any +to_openai_agents(def_promise: Any, opts: dict[str, Any] | None = None) -> Any diff --git a/packages/openai-messages/api-report/launchdarkly_ai_openai_messages.txt b/packages/openai-messages/api-report/launchdarkly_ai_openai_messages.txt new file mode 100644 index 00000000..23e9598e --- /dev/null +++ b/packages/openai-messages/api-report/launchdarkly_ai_openai_messages.txt @@ -0,0 +1,3 @@ +# Public API of launchdarkly_ai_openai_messages. Generated by `make api-report`; do not edit. +create_openai_messages_handler(*, capture_content: bool | None = None) -> ProviderHandler +openai_messages(config_key: str, user_input: str, context: LDContext, **kwargs: Any) -> Any diff --git a/packages/opencode-agents/api-report/launchdarkly_ai_opencode_agents.txt b/packages/opencode-agents/api-report/launchdarkly_ai_opencode_agents.txt new file mode 100644 index 00000000..c3e6ce4c --- /dev/null +++ b/packages/opencode-agents/api-report/launchdarkly_ai_opencode_agents.txt @@ -0,0 +1,4 @@ +# Public API of launchdarkly_ai_opencode_agents. Generated by `make api-report`; do not edit. +create_opencode_handler(*, base_url: str, capture_content: bool | None = None, model: ModelResolver | None = None, agent: str | None = None, client: httpx.AsyncClient | None = None) -> ProviderHandler +opencode_agents(config_key: str, user_input: str, context: dict[str, Any], **kwargs: Any) -> Any +OpenCodeSessionError(Exception) [class] diff --git a/pyproject.toml b/pyproject.toml index 0dae9678..cad81ab9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dev = [ "pre-commit>=4.6.0", "brotlicffi>=1.0", "langchain-aws>=1", + "griffelib>=2.1.0", ] [tool.pytest.ini_options] diff --git a/scripts/api_report.py b/scripts/api_report.py new file mode 100644 index 00000000..ca1f2f9b --- /dev/null +++ b/scripts/api_report.py @@ -0,0 +1,184 @@ +"""Generate or check the API report of every public module. + +A report records each name in a module's ``__all__`` with its full signature, one +unindented line per name; a class's public members follow on lines indented by two +spaces. Reports live at ``packages//api-report/.txt`` and are the +package's public API contract: any change to a public name or signature shows up as a +diff in a committed file. + + uv run python scripts/api_report.py # write every report + uv run python scripts/api_report.py --check # fail if any report is stale +""" + +from __future__ import annotations + +import difflib +import sys +from pathlib import Path +from typing import Any + +import griffe + +ROOT = Path(__file__).resolve().parent.parent +# Re-exports the core package unchanged, so the core report covers it. +SKIP = {"launchdarkly_ai_python"} +HEADER = "# Public API of {module}. Generated by `make api-report`; do not edit.\n" + + +def public_modules(root: Path) -> dict[str, Path]: + """Map each reported module to its package directory: each package root, and + each module of a package's ``experimental`` subpackage.""" + found: dict[str, Path] = {} + for init in sorted(root.glob("packages/*/src/*/__init__.py")): + package_dir, name = init.parents[2], init.parent.name + if name in SKIP: + continue + found[name] = package_dir + experimental = init.parent / "experimental" + for sub in sorted(experimental.glob("*.py")) if experimental.is_dir() else []: + if not sub.name.startswith("_"): + found[f"{name}.experimental.{sub.stem}"] = package_dir + return found + + +def resolve(loader: griffe.GriffeLoader, module: Any, name: str) -> Any: + """Return what ``from module import name`` gives an application. + + An imported name wins over a submodule of the same name, as it does at run + time (``from .graph import graph`` binds the function, not the module). + """ + obj = module.members.get(name) + path = module.imports.get(name) + if path: + try: + obj = loader.modules_collection.get_member(path) + except KeyError: # imported from another distribution; keep the alias + pass + if obj is None: + raise SystemExit(f"{module.path}: {name} is in __all__ but not defined") + while obj.is_alias: + try: + obj = obj.target + except griffe.AliasResolutionError: + return obj + return obj + + +# Labels that change how a caller uses a name; decorators are shown separately. +SHOWN_LABELS = {"abstractmethod", "async", "classmethod", "staticmethod"} + + +# Decorators already shown as a label, or as the [property] tag. +LABEL_DECORATORS = {"abstractmethod", "classmethod", "property", "staticmethod"} + + +def tags(obj: Any, *extra: str) -> str: + labels = [*extra, *sorted(obj.labels & SHOWN_LABELS)] + decorators = [ + f"@{d.value}" + for d in getattr(obj, "decorators", []) + if str(d.value).split(".")[-1] not in LABEL_DECORATORS | {"setter", "deleter"} + ] + shown = labels + decorators + return f" [{', '.join(shown)}]" if shown else "" + + +def call(obj: Any, name: str) -> str: + """``name(params) -> returns``, under the name the caller uses.""" + signature = obj.signature() + return ( + name + signature[len(obj.name) :] + if signature.startswith(obj.name) + else signature + ) + + +def describe(obj: Any, name: str, indent: str = "") -> list[str]: + if obj.is_alias: # an unresolved re-export from another distribution + return [f"{indent}{name} = {obj.target_path} [re-export]"] + if obj.is_module: + return [f"{indent}{name} [module]"] + if obj.is_function: + if "property" in obj.labels: + return [f"{indent}{name}: {obj.returns}{tags(obj, 'property')}"] + return [f"{indent}{call(obj, name)}{tags(obj)}"] + if obj.is_type_alias: + return [f"{indent}{name} = {obj.value} [type]"] + if obj.is_attribute: + annotation = f": {obj.annotation}" if obj.annotation is not None else "" + if "property" in obj.labels: + return [f"{indent}{name}{annotation}{tags(obj, 'property')}"] + value = f" = {obj.value}" if obj.value is not None else "" + return [f"{indent}{name}{annotation}{value}{tags(obj)}"] + # A class: its bases, then its constructor and public members. + bases = f"({', '.join(str(b) for b in obj.bases)})" if obj.bases else "" + lines = [f"{indent}{name}{bases}{tags(obj, 'class')}"] + if "__init__" in obj.members: + lines.append(f"{indent} {call(obj, '__init__')}") + members = sorted( + (m for m in obj.members.values() if not m.name.startswith("_")), + key=lambda m: m.name.lower(), + ) + for member in members: + lines += describe(member, member.name, indent + " ") + return lines + + +def report(loader: griffe.GriffeLoader, module_path: str) -> str: + module = loader.modules_collection.get_member(module_path) + if not module.exports: + raise SystemExit(f"{module_path}: no __all__") + lines = [HEADER.format(module=module_path).rstrip("\n")] + for name in sorted(module.exports, key=lambda n: (n.lower(), n)): + lines += describe(resolve(loader, module, str(name)), str(name)) + return "\n".join(lines) + "\n" + + +def reports(root: Path = ROOT) -> dict[Path, str]: + """Return {report path: contents} for every public module under ``root``.""" + modules = public_modules(root) + loader = griffe.GriffeLoader( + search_paths=sorted(str(p) for p in root.glob("packages/*/src")) + ) + for top in sorted({m.split(".")[0] for m in modules}): + loader.load(top) + loader.resolve_aliases(external=False, implicit=False) + return { + package / "api-report" / f"{module}.txt": report(loader, module) + for module, package in modules.items() + } + + +def main(argv: list[str]) -> int: + expected = reports() + if argv != ["--check"]: + for path, text in expected.items(): + path.parent.mkdir(exist_ok=True) + path.write_text(text) + print(f"wrote {len(expected)} API reports") + return 0 + stale = [] + for path in sorted(set(expected) | set(ROOT.glob("packages/*/api-report/*.txt"))): + current = path.read_text() if path.exists() else "" + if current != expected.get(path, ""): + stale.append(path) + rel = str(path.relative_to(ROOT)) + sys.stdout.writelines( + difflib.unified_diff( + current.splitlines(keepends=True), + expected.get(path, "").splitlines(keepends=True), + f"{rel} (committed)", + f"{rel} (from the code)", + ) + ) + if stale: + print( + f"\n{len(stale)} API report(s) out of date: run `make api-report` and commit the result." + ) + return 1 + print(f"{len(expected)} API reports are current") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tests/test_api_report.py b/tests/test_api_report.py new file mode 100644 index 00000000..d62d30db --- /dev/null +++ b/tests/test_api_report.py @@ -0,0 +1,141 @@ +"""The committed API reports are current, and the generator records what callers see.""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pytest + +from scripts import api_report + + +def test_committed_reports_are_current(capsys: pytest.CaptureFixture[str]) -> None: + # A stale report means a public name or signature changed without its report. + assert api_report.main(["--check"]) == 0, capsys.readouterr().out + + +def write(path: Path, source: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(textwrap.dedent(source)) + + +@pytest.fixture +def workspace(tmp_path: Path) -> Path: + src = tmp_path / "packages/demo/src/demo_sdk" + write( + src / "__init__.py", + """ + from json import JSONDecoder + from demo_sdk.run import run + from demo_sdk.types import Options, Result, Usage, make, LIMIT + + __all__ = ["JSONDecoder", "LIMIT", "Options", "Result", "Usage", "make", "run"] + """, + ) + write( + src / "run.py", + """ + async def run(key: str, *, retries: int = 3, **extra: object) -> str: + return key + """, + ) + write( + src / "types.py", + """ + from dataclasses import dataclass + + LIMIT: int = 10 + Options = dict[str, object] + + @dataclass(frozen=True) + class Usage: + total: int = 0 + _secret: int = 0 + + class Result: + def __init__(self, value: str) -> None: + self.value = value + + @property + def size(self) -> int: + return len(self.value) + + @classmethod + def empty(cls) -> "Result": + return cls("") + + def _hidden(self) -> None: ... + + def make(value: str, /, *items: int) -> Result: + return Result(value) + """, + ) + write(src / "experimental/__init__.py", "") + write( + src / "experimental/beta.py", + """ + def try_it() -> None: ... + + __all__ = ["try_it"] + """, + ) + write(tmp_path / "packages/umbrella/src/launchdarkly_ai_python/__init__.py", "") + return tmp_path + + +def test_report_contents(workspace: Path) -> None: + reports = api_report.reports(workspace) + package = workspace / "packages/demo" + assert set(reports) == { + package / "api-report/demo_sdk.txt", + package / "api-report/demo_sdk.experimental.beta.txt", + } + assert reports[package / "api-report/demo_sdk.txt"] == textwrap.dedent( + """\ + # Public API of demo_sdk. Generated by `make api-report`; do not edit. + JSONDecoder = json.JSONDecoder [re-export] + LIMIT: int = 10 + make(value: str, /, *items: int) -> Result + Options = dict[str, object] + Result [class] + __init__(value: str) + empty() -> Result [classmethod] + size: int [property] + value = value + run(key: str, *, retries: int = 3, **extra: object) -> str [async] + Usage [class, @dataclass(frozen=True)] + __init__(total: int = 0, _secret: int = 0) + total: int = 0 + """ + ) + assert reports[package / "api-report/demo_sdk.experimental.beta.txt"].endswith( + "try_it() -> None\n" + ) + + +def test_an_imported_name_wins_over_a_submodule(workspace: Path) -> None: + # `from demo_sdk.run import run` binds the function, not the `run` module. + text = api_report.reports(workspace)[ + workspace / "packages/demo/api-report/demo_sdk.txt" + ] + assert "run(key: str" in text + assert "run [module]" not in text + + +def test_a_module_without_all_fails(workspace: Path) -> None: + write( + workspace / "packages/demo/src/demo_sdk/experimental/beta.py", + "def try_it() -> None: ...\n", + ) + with pytest.raises(SystemExit, match="no __all__"): + api_report.reports(workspace) + + +def test_an_undefined_name_in_all_fails(workspace: Path) -> None: + write( + workspace / "packages/demo/src/demo_sdk/experimental/beta.py", + '__all__ = ["missing"]\n', + ) + with pytest.raises(SystemExit, match="missing is in __all__ but not defined"): + api_report.reports(workspace) diff --git a/uv.lock b/uv.lock index b7c145b0..7545a687 100644 --- a/uv.lock +++ b/uv.lock @@ -28,6 +28,7 @@ members = [ dev = [ { name = "brotlicffi", specifier = ">=1.0" }, { name = "claude-agent-sdk", specifier = ">=0.2.110" }, + { name = "griffelib", specifier = ">=2.1.0" }, { name = "langchain-anthropic", specifier = ">=1.4.8" }, { name = "langchain-aws", specifier = ">=1" }, { name = "langchain-openai", specifier = ">=0.3" }, From 0d155e6e95549a638ef36a51407fcc291cbb640d Mon Sep 17 00:00:00 2001 From: Jeff Dupont Date: Thu, 22 Oct 2026 09:19:05 -0700 Subject: [PATCH 2/3] chore(AIC-3500): include public dunders and TypedDict keywords in API reports Co-Authored-By: Claude Opus 5.5 --- ...kly_ai_server.experimental.evaluations.txt | 4 ++- ...chdarkly_ai_server.experimental.skills.txt | 4 +++ .../api-report/launchdarkly_ai_server.txt | 2 ++ scripts/api_report.py | 13 ++++++-- tests/test_api_report.py | 33 +++++++++++++++++++ 5 files changed, 52 insertions(+), 4 deletions(-) diff --git a/packages/client/api-report/launchdarkly_ai_server.experimental.evaluations.txt b/packages/client/api-report/launchdarkly_ai_server.experimental.evaluations.txt index b4abf15e..8c7dde8f 100644 --- a/packages/client/api-report/launchdarkly_ai_server.experimental.evaluations.txt +++ b/packages/client/api-report/launchdarkly_ai_server.experimental.evaluations.txt @@ -36,7 +36,7 @@ EvaluationsModule [class] sdk_key: str | None [property] tools: ToolsClient [property] ui_base_uri: str [property] -GenerationConfig(TypedDict) [class] +GenerationConfig(TypedDict, total=False) [class] instructions: str messages: list[dict[str, Any]] model: str @@ -47,6 +47,7 @@ GenerationConfig(TypedDict) [class] init_evaluations(project_key: str | None = None, api_key: str | None = None, sdk_key: str | None = None, base_uri: str | None = None, ui_base_uri: str | None = None, transport: Transport = urllib_transport) -> EvaluationsModule Judge [class, @dataclass(frozen=True)] __init__(key: str, threshold: float | None = DEFAULT_JUDGE_THRESHOLD, pass_rate_threshold: float | None = None, ground_truth_context: str | None = None) + __post_init__() -> None criterion_type: str [property] ground_truth_context: str | None = None key: str @@ -63,6 +64,7 @@ RunSummary [class, @dataclass] total_rows: int = 0 Scorer [class, @dataclass(frozen=True)] __init__(name: str, fn: ScorerFn, threshold: float | None = 1.0, pass_rate_threshold: float | None = None, success_direction: SuccessDirection = 'higher_is_better') + __post_init__() -> None criterion_type: str [property] fn: ScorerFn name: str diff --git a/packages/client/api-report/launchdarkly_ai_server.experimental.skills.txt b/packages/client/api-report/launchdarkly_ai_server.experimental.skills.txt index 18639cd0..11f3fee8 100644 --- a/packages/client/api-report/launchdarkly_ai_server.experimental.skills.txt +++ b/packages/client/api-report/launchdarkly_ai_server.experimental.skills.txt @@ -2,6 +2,8 @@ all_skills() -> list[Skill] [async] FDv2SkillStore [class] __init__(sdk_key: str, *, base_uri: str | None = None, stream_uri: str | None = None, mode: Mode = 'stream', poll_interval: float = 30.0, read_timeout: float | None = None, initial_backoff: float = 1.0, max_backoff: float = 30.0, _requester: Any = None) + __enter__() -> FDv2SkillStore + __exit__(*_exc: Any) -> None add_listener(kind: str, fn: Callable[[dict[str, Any]], Any]) -> None all_objects(kind: str) -> dict[str, dict[str, Any]] close(timeout: float = 5.0) -> None @@ -64,6 +66,8 @@ SkillStore(Protocol) [class] get_object(kind: str, key: str, version: int | None = None) -> dict[str, Any] | None SkillWatcher [class] __init__(request: Sequence[Skill | SkillReference | str] | str, root: str | os.PathLike[str], store: Any, *, prune: bool, timeout: float, on_unavailable: OnUnavailable, debounce: float, on_reconcile: Callable[[ReconcileReport], Any] | None) + __enter__() -> SkillWatcher + __exit__(*_exc: Any) -> None close(timeout: float = 15.0) -> None notify(_raw: Any = None) -> None reconciles: int [property] diff --git a/packages/client/api-report/launchdarkly_ai_server.txt b/packages/client/api-report/launchdarkly_ai_server.txt index ecc9ea06..fd1861f4 100644 --- a/packages/client/api-report/launchdarkly_ai_server.txt +++ b/packages/client/api-report/launchdarkly_ai_server.txt @@ -148,6 +148,7 @@ ProviderGraphResponse [class, @dataclass] usage: UsageDict ProviderHandler [class] __init__(fn: _HandlerFn, provides_for: tuple[str, Literal['agent', 'messages']] | None = None, stream_fn: _StreamFn | None = None, capture_content: bool | None = None) + __call__(config: AiConfigRep, user_input: str | None = None, tool_handlers: dict[str, Any] | None = None, variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None) -> HandlerResult [async] capture_content: bool [property] has_stream: bool [property] provides_for: tuple[str, Literal['agent', 'messages']] | None = provides_for @@ -180,6 +181,7 @@ SDK_INFO_CONTEXT: LDContext = {'kind': 'ld_ai', 'key': 'ld-internal-tracking', ' SDK_INFO_EVENT = '$ld:ai:sdk:info' SDKOptions [class, @dataclass(frozen=True)] __init__(capture_content: bool = False) + __post_init__() -> None capture_content: bool = False set_conversation_id_if_absent(span: Any, conversation: str) -> None set_input_content_attributes(span: Any, capture: bool, *, system_instructions: str | None = None, messages: list[SpanMessage] | None = None, tool_definitions: list[ToolDefinitionInput] | None = None) -> None diff --git a/scripts/api_report.py b/scripts/api_report.py index ca1f2f9b..89605c7f 100644 --- a/scripts/api_report.py +++ b/scripts/api_report.py @@ -110,13 +110,20 @@ def describe(obj: Any, name: str, indent: str = "") -> list[str]: return [f"{indent}{name}{annotation}{tags(obj, 'property')}"] value = f" = {obj.value}" if obj.value is not None else "" return [f"{indent}{name}{annotation}{value}{tags(obj)}"] - # A class: its bases, then its constructor and public members. - bases = f"({', '.join(str(b) for b in obj.bases)})" if obj.bases else "" + # A class: its bases and keywords (e.g. ``total=False``), then its constructor and + # public members, dunders included. + args = [*map(str, obj.bases), *(f"{k}={v}" for k, v in obj.keywords.items())] + bases = f"({', '.join(args)})" if args else "" lines = [f"{indent}{name}{bases}{tags(obj, 'class')}"] if "__init__" in obj.members: lines.append(f"{indent} {call(obj, '__init__')}") members = sorted( - (m for m in obj.members.values() if not m.name.startswith("_")), + ( + m + for m in obj.members.values() + if m.name != "__init__" + and (not m.name.startswith("_") or m.name.endswith("__")) + ), key=lambda m: m.name.lower(), ) for member in members: diff --git a/tests/test_api_report.py b/tests/test_api_report.py index d62d30db..800d26e9 100644 --- a/tests/test_api_report.py +++ b/tests/test_api_report.py @@ -139,3 +139,36 @@ def test_an_undefined_name_in_all_fails(workspace: Path) -> None: ) with pytest.raises(SystemExit, match="missing is in __all__ but not defined"): api_report.reports(workspace) + + +def test_dunders_and_class_keywords_are_reported(workspace: Path) -> None: + write( + workspace / "packages/demo/src/demo_sdk/experimental/beta.py", + """ + from typing import TypedDict + + class Config(TypedDict, total=False): + model: str + + class Runner: + def __init__(self) -> None: ... + def __call__(self, key: str) -> int: ... + def _private(self) -> None: ... + + __all__ = ["Config", "Runner"] + """, + ) + text = api_report.reports(workspace)[ + workspace / "packages/demo/api-report/demo_sdk.experimental.beta.txt" + ] + assert text.endswith( + textwrap.dedent( + """\ + Config(TypedDict, total=False) [class] + model: str + Runner [class] + __init__() + __call__(key: str) -> int + """ + ) + ) From 33dcfde56e9b7fc86c52cf963f61d8051e7a8228 Mon Sep 17 00:00:00 2001 From: Jeff Dupont Date: Thu, 22 Oct 2026 09:45:44 -0700 Subject: [PATCH 3/3] chore(AIC-3500): remove stale API reports when writing them Co-Authored-By: Claude Opus 5.5 --- scripts/api_report.py | 5 ++++- tests/test_api_report.py | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/scripts/api_report.py b/scripts/api_report.py index 89605c7f..5251ebb6 100644 --- a/scripts/api_report.py +++ b/scripts/api_report.py @@ -157,11 +157,14 @@ def reports(root: Path = ROOT) -> dict[Path, str]: def main(argv: list[str]) -> int: - expected = reports() + expected = reports(ROOT) if argv != ["--check"]: for path, text in expected.items(): path.parent.mkdir(exist_ok=True) path.write_text(text) + # A removed or renamed module's old report would otherwise stay and fail --check. + for path in set(ROOT.glob("packages/*/api-report/*.txt")) - set(expected): + path.unlink() print(f"wrote {len(expected)} API reports") return 0 stale = [] diff --git a/tests/test_api_report.py b/tests/test_api_report.py index 800d26e9..c8e1b1b6 100644 --- a/tests/test_api_report.py +++ b/tests/test_api_report.py @@ -123,6 +123,18 @@ def test_an_imported_name_wins_over_a_submodule(workspace: Path) -> None: assert "run [module]" not in text +def test_writing_removes_a_stale_report( + workspace: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(api_report, "ROOT", workspace) + stale = workspace / "packages/demo/api-report/demo_sdk.gone.txt" + stale.parent.mkdir() + stale.write_text("old\n") + assert api_report.main([]) == 0 + assert not stale.exists() + assert api_report.main(["--check"]) == 0 + + def test_a_module_without_all_fails(workspace: Path) -> None: write( workspace / "packages/demo/src/demo_sdk/experimental/beta.py",