From 95ea738e35d923015f7133604e9615625af7e23c Mon Sep 17 00:00:00 2001 From: Devops Bot Date: Wed, 30 Sep 2026 10:06:42 +0000 Subject: [PATCH 1/3] fix: Add research models and clarify news lookup usage --- README.md | 19 ++++++++++--- asknews_sdk/api/chat.py | 2 ++ asknews_sdk/dto/alert.py | 3 +++ tests/api/test_chat.py | 39 +++++++++++++++++++++++++++ tests/api/test_chat_model_types.py | 38 +++++++++++++++++++++++--- tests/test_readme.py | 43 ++++++++++++++++++++++++++++++ 6 files changed, 136 insertions(+), 8 deletions(-) create mode 100644 tests/test_readme.py diff --git a/README.md b/README.md index 622c1e5..72ba28d 100644 --- a/README.md +++ b/README.md @@ -13,21 +13,32 @@ pip install asknews ## Usage +- Use `ask.news.search_news()` for a fast, surgical, single-search lookup when you already know the keywords or entities and do not need iterative discovery. +- Use DeepNews (`ask.chat.get_deep_news()` in this SDK) to attach your agent to deep, iterative research across news and other sources: finding and following leads, identifying unknown entities, and connecting evidence across sources. + ```python from asknews_sdk import AskNewsSDK ask = AskNewsSDK( - api_key="" + api_key="", scopes=["news", "chat", "stories", "analytics"] ) -query = "Effect of fed policy on tech sector" +query = "NVIDIA earnings" -# prompt-optimized string ready to go for any LLM: +# Fast lookup: prompt-optimized news context for your LLM. news_context = ask.news.search_news(query).as_string + +# Iterative discovery across news and other sources. +research = ask.chat.get_deep_news( + messages=[{"role": "user", "content": "Find and follow leads on AI chip supply risks."}], + sources=["asknews", "google", "wiki"], + engine="v1.5", +) +print(research.choices[0].message.content) ``` -And you will have a prompt-optimized string ready to go for any LLM. The API doesn't stop there, explore a wide range of endpoints: +The API doesn't stop there, explore a wide range of endpoints: - /stories, high level event tracking and state of the art article clustering - /forecasts, industry leading forecasting on any real-time event diff --git a/asknews_sdk/api/chat.py b/asknews_sdk/api/chat.py index c360c15..de342b4 100644 --- a/asknews_sdk/api/chat.py +++ b/asknews_sdk/api/chat.py @@ -68,10 +68,12 @@ "claude-opus-4-5-20251101", "claude-sonnet-4-6", "claude-sonnet-5", + "claude-sonnet-5-5", "gemini-2.5-flash", "o3", "gpt-5.6-sol", "gpt-6-sol", + "gpt-6.1-sol", "claude-opus-4-8", "claude-opus-5-5", "gpt-5.6-terra", diff --git a/asknews_sdk/dto/alert.py b/asknews_sdk/dto/alert.py index dc9abf1..cfda476 100644 --- a/asknews_sdk/dto/alert.py +++ b/asknews_sdk/dto/alert.py @@ -26,6 +26,7 @@ "gemini-3-pro", "claude-sonnet-4-6", "claude-sonnet-5", + "claude-sonnet-5-5", "claude-opus-4-6", "claude-fable-5", "claude-fable-5-1", @@ -35,7 +36,9 @@ "open-source-best", "gemini-3-flash", "gpt-5.6-sol", + "gpt-6.1-sol", "claude-opus-4-8", + "claude-opus-5-5", "gpt-5.6-terra", ] diff --git a/tests/api/test_chat.py b/tests/api/test_chat.py index 618f0b4..6b33d6e 100644 --- a/tests/api/test_chat.py +++ b/tests/api/test_chat.py @@ -1,3 +1,4 @@ +import json from inspect import isasyncgen, isgenerator import pytest @@ -12,13 +13,19 @@ HeadlineQuestionsResponse, ListModelResponse, ) +from asknews_sdk.dto.deepnews import CreateDeepNewsResponse from asknews_sdk.utils import build_accept_header +from tests.api.test_chat_model_types import ADVANCED_DEEPNEWS_MODELS class MockCreateChatCompletionResponse(ModelFactory[CreateChatCompletionResponse]): ... +class MockCreateDeepNewsResponse(ModelFactory[CreateDeepNewsResponse]): + sources = {"news": [], "web": [], "charts": []} + + class MockCreateChatCompletionResponseStream(ModelFactory[CreateChatCompletionResponseStream]): ... @@ -41,6 +48,38 @@ def async_chat_api(async_api_client: AsyncAPIClient): return AsyncChatAPI(async_api_client) +@pytest.mark.parametrize("model", ADVANCED_DEEPNEWS_MODELS) +def test_sync_deepnews_model_request(sync_chat_api: ChatAPI, response_mock: MockRouter, model): + mock_response = MockCreateDeepNewsResponse.build(model=model) + route = response_mock.post("/v1/chat/deepnews").respond( + content=mock_response.model_dump_json() + ) + + response = sync_chat_api.get_deep_news( + messages=[{"role": "user", "content": "query"}], model=model + ) + + assert response.model_dump(mode="json") == mock_response.model_dump(mode="json") + assert json.loads(route.calls.last.request.content)["model"] == model + + +@pytest.mark.parametrize("model", ADVANCED_DEEPNEWS_MODELS) +async def test_async_deepnews_model_request( + async_chat_api: AsyncChatAPI, response_mock: MockRouter, model +): + mock_response = MockCreateDeepNewsResponse.build(model=model) + route = response_mock.post("/v1/chat/deepnews").respond( + content=mock_response.model_dump_json() + ) + + response = await async_chat_api.get_deep_news( + messages=[{"role": "user", "content": "query"}], model=model + ) + + assert response.model_dump(mode="json") == mock_response.model_dump(mode="json") + assert json.loads(route.calls.last.request.content)["model"] == model + + def test_sync_chat_api_get_chat_completions(sync_chat_api: ChatAPI, response_mock: MockRouter): mock_response = MockCreateChatCompletionResponse.build() diff --git a/tests/api/test_chat_model_types.py b/tests/api/test_chat_model_types.py index 7e9e1b3..cb6aad7 100644 --- a/tests/api/test_chat_model_types.py +++ b/tests/api/test_chat_model_types.py @@ -3,7 +3,7 @@ from typing import get_args, get_type_hints import pytest -from pydantic import BaseModel +from pydantic import BaseModel, TypeAdapter, ValidationError from asknews_sdk.api.chat import AsyncChatAPI, ChatAPI, ChatModel, DeepNewsModel from asknews_sdk.dto import alert as alert_dto @@ -12,7 +12,8 @@ GPT_6_ASTRA = "gpt-6-astra" CLAUDE_FABLE_MODELS = ("claude-fable-5", "claude-fable-5-1") -ADVANCED_DEEPNEWS_MODELS = ("claude-opus-5-5", "gpt-6-sol") +NEW_DEEPNEWS_MODELS = ("claude-sonnet-5-5", "claude-opus-5-5", "gpt-6.1-sol") +ADVANCED_DEEPNEWS_MODELS = (*NEW_DEEPNEWS_MODELS, "gpt-6-sol") def test_gpt_6_astra_is_a_direct_deepnews_model_only(): @@ -45,11 +46,10 @@ def test_gpt_6_astra_is_absent_from_alert_model_contracts(): @pytest.mark.parametrize("model", ADVANCED_DEEPNEWS_MODELS) @pytest.mark.parametrize("api_type", [ChatAPI, AsyncChatAPI]) -def test_advanced_models_are_scoped_to_deepnews_and_preserve_alert_contracts(model, api_type): +def test_advanced_models_are_scoped_to_deepnews_and_preserve_legacy_contracts(model, api_type): assert model in get_args(DeepNewsModel) assert model in get_args(get_type_hints(api_type.get_deep_news)["model"]) assert model not in get_args(ChatModel) - assert model not in get_args(alert_dto.DeepNewsModel) assert model not in get_args(alert_dto.CheckAlertModel) assert model not in get_args(alert_dto.AlertReportModel) @@ -61,6 +61,36 @@ def test_advanced_deepnews_models_serialize_in_requests(model): assert request.model_dump(mode="json")["model"] == model +@pytest.mark.parametrize("model", NEW_DEEPNEWS_MODELS) +@pytest.mark.parametrize( + "request_type", [alert_dto.CreateAlertRequest, alert_dto.UpdateAlertRequest] +) +def test_active_alert_source_and_report_models_validate_and_serialize(model, request_type): + assert model in get_args(alert_dto.DeepNewsModel) + request = request_type( + cron="0 9 * * *", + triggers=[], + sources=[{"identifier": "deepnews", "params": {"model": model}}], + report={"identifier": "deepnews", "params": {"model": model}}, + ) + payload = json.loads(request.model_dump_json()) + + assert payload["sources"][0]["params"]["model"] == model + assert payload["report"]["params"]["model"] == model + + +@pytest.mark.parametrize("model_type", [DeepNewsModel, alert_dto.DeepNewsModel]) +def test_deepnews_model_contracts_reject_misspelled_gpt_id(model_type): + with pytest.raises(ValidationError): + TypeAdapter(model_type).validate_python("gpt-sol-6.1") + + +def test_active_alert_model_defaults_are_unchanged(): + assert alert_dto.DeepNewsSourceParams().model == "gemini-3-flash" + assert alert_dto.DeepNewsReportParams().model == "claude-sonnet-4-6" + assert alert_dto.LegacyReportRequest().model == "claude-sonnet-4-6" + + @pytest.mark.parametrize("model", CLAUDE_FABLE_MODELS) def test_claude_fable_models_match_deepnews_and_alert_report_contracts(model): assert model in get_args(DeepNewsModel) diff --git a/tests/test_readme.py b/tests/test_readme.py new file mode 100644 index 0000000..ef1463e --- /dev/null +++ b/tests/test_readme.py @@ -0,0 +1,43 @@ +import json +import re +from pathlib import Path + +from tests.api.test_chat import MockCreateDeepNewsResponse +from tests.api.test_news import MockSearchResponse + + +def test_readme_usage_example_runs_with_mocked_responses(response_mock, capsys): + readme = (Path(__file__).resolve().parents[1] / "README.md").read_text() + examples = re.findall(r"```python\n(.*?)```", readme, re.DOTALL) + assert examples + + news_response = MockSearchResponse.build(as_string="News context") + deepnews_response = MockCreateDeepNewsResponse.build() + news_route = response_mock.get("/v1/news/search").respond( + content=news_response.model_dump_json() + ) + research_route = response_mock.post("/v1/chat/deepnews").respond( + content=deepnews_response.model_dump_json() + ) + + namespace = {} + try: + for example in examples: + exec(compile(example, "README.md", "exec"), namespace) + assert namespace["news_context"] == "News context" + assert namespace["research"].model_dump(mode="json") == deepnews_response.model_dump( + mode="json" + ) + assert capsys.readouterr().out.strip() == ( + deepnews_response.choices[0].message.content.strip() + ) + assert news_route.calls.last.request.url.params["query"] == namespace["query"] + payload = json.loads(research_route.calls.last.request.content) + assert payload["messages"][0]["role"] == "user" + assert payload["sources"] == ["asknews", "google", "wiki"] + assert payload["engine"] == "v1.5" + assert payload["stream"] is False + assert payload["model"] == "claude-sonnet-4-6" + finally: + if "ask" in namespace: + namespace["ask"].close() From d4477ac1b8201614842af32251c6bb11897d9ea7 Mon Sep 17 00:00:00 2001 From: Devops Bot Date: Mon, 5 Oct 2026 23:09:37 +0000 Subject: [PATCH 2/3] fix: Sync additive SDK contracts with production 0.32.3 --- asknews_sdk/api/chat.py | 24 +++ asknews_sdk/api/distribution.py | 80 ++++++++- asknews_sdk/dto/__init__.py | 4 + asknews_sdk/dto/alert.py | 25 +++ asknews_sdk/dto/distribution.py | 15 +- tests/api/test_chat.py | 6 +- tests/api/test_chat_model_types.py | 56 ++++-- tests/api/test_distribution.py | 115 +++++++++++- tests/fixtures/production_contract_0323.json | 179 +++++++++++++++++++ 9 files changed, 480 insertions(+), 24 deletions(-) create mode 100644 tests/fixtures/production_contract_0323.json diff --git a/asknews_sdk/api/chat.py b/asknews_sdk/api/chat.py index de342b4..621b940 100644 --- a/asknews_sdk/api/chat.py +++ b/asknews_sdk/api/chat.py @@ -77,6 +77,30 @@ "claude-opus-4-8", "claude-opus-5-5", "gpt-5.6-terra", + "o1-mini", + "o1", + "gpt-4o-mini", + "gpt-4o", + "gpt-4.1-2025-04-14", + "gpt-5-mini", + "command-nightly", + "claude-3-5-sonnet-20240620", + "claude-3-5-sonnet-latest", + "claude-3-7-sonnet-20250219", + "claude-3-7-sonnet-nothinking", + "claude-opus-5", + "meta-llama/Meta-Llama-3-8B-Instruct", + "meta-llama/Meta-Llama-3.1-405B-Instruct", + "meta-llama/Meta-Llama-3.3-70B-Instruct", + "meta-llama/Llama-4-Maverick-17B-128E-Instruct", + "gemini-2.5-pro-preview-03-25", + "gemini-3.1-pro", + "gemini-3-flash", + "gemini-3.5-flash", + "gemini-3.6-flash", + "open-source-best", + "kimi-k2p5", + "kimi-k3", ] diff --git a/asknews_sdk/api/distribution.py b/asknews_sdk/api/distribution.py index abc8654..b784412 100644 --- a/asknews_sdk/api/distribution.py +++ b/asknews_sdk/api/distribution.py @@ -1,8 +1,12 @@ -from typing import Dict, List, Optional +from typing import Dict, List, Literal, Optional from asknews_sdk.api.base import BaseAPI from asknews_sdk.client import APIClient, AsyncAPIClient -from asknews_sdk.dto.distribution import DomainMetricsResponse, DomainMetricsTimeWindowResponse +from asknews_sdk.dto.distribution import ( + DomainMetricsResponse, + DomainMetricsTimeWindowResponse, + TopNArticlesForDomainResponse, +) class DistributionAPI(BaseAPI[APIClient]): @@ -52,6 +56,42 @@ def get_domain_metrics_timeseries( ) return DomainMetricsTimeWindowResponse.model_validate(response.content) + def get_top_n_articles_for_domains( + self, + domain_names: List[str], + limit: int = 10, + page: int = 1, + start_date: Optional[int] = None, + end_date: Optional[int] = None, + metric: Literal["surface", "citation", "grounded"] = "surface", + *, + http_headers: Optional[Dict] = None, + ) -> TopNArticlesForDomainResponse: + """Get top domain articles ranked by raw counts for the selected metric. + + :param domain_names: Domain names to filter by. + :param limit: Page size (1–100), defaults to 10. + :param page: Page number (1-based), defaults to 1. + :param start_date: Start timestamp in seconds since epoch. + :param end_date: End timestamp in seconds since epoch. + :param metric: Ranking metric: surface, citation, or grounded. + """ + response = self.client.request( + method="GET", + endpoint="/v1/distribution/articles/top_n_for_domains", + query={ + "domain_names": domain_names, + "limit": limit, + "page": page, + "start_date": start_date, + "end_date": end_date, + "metric": metric, + }, + headers=http_headers, + accept=[(TopNArticlesForDomainResponse.__content_type__, 1.0)], + ) + return TopNArticlesForDomainResponse.model_validate(response.content) + class AsyncDistributionAPI(BaseAPI[AsyncAPIClient]): """Distribution API (async).""" @@ -99,3 +139,39 @@ async def get_domain_metrics_timeseries( accept=[(DomainMetricsTimeWindowResponse.__content_type__, 1.0)], ) return DomainMetricsTimeWindowResponse.model_validate(response.content) + + async def get_top_n_articles_for_domains( + self, + domain_names: List[str], + limit: int = 10, + page: int = 1, + start_date: Optional[int] = None, + end_date: Optional[int] = None, + metric: Literal["surface", "citation", "grounded"] = "surface", + *, + http_headers: Optional[Dict] = None, + ) -> TopNArticlesForDomainResponse: + """Get top domain articles ranked by raw counts for the selected metric. + + :param domain_names: Domain names to filter by. + :param limit: Page size (1–100), defaults to 10. + :param page: Page number (1-based), defaults to 1. + :param start_date: Start timestamp in seconds since epoch. + :param end_date: End timestamp in seconds since epoch. + :param metric: Ranking metric: surface, citation, or grounded. + """ + response = await self.client.request( + method="GET", + endpoint="/v1/distribution/articles/top_n_for_domains", + query={ + "domain_names": domain_names, + "limit": limit, + "page": page, + "start_date": start_date, + "end_date": end_date, + "metric": metric, + }, + headers=http_headers, + accept=[(TopNArticlesForDomainResponse.__content_type__, 1.0)], + ) + return TopNArticlesForDomainResponse.model_validate(response.content) diff --git a/asknews_sdk/dto/__init__.py b/asknews_sdk/dto/__init__.py index 4187618..ec071b5 100644 --- a/asknews_sdk/dto/__init__.py +++ b/asknews_sdk/dto/__init__.py @@ -16,6 +16,8 @@ DomainMetricsDayItem, DomainMetricsResponse, DomainMetricsTimeWindowResponse, + TopNArticlesForDomainItem, + TopNArticlesForDomainResponse, ) from asknews_sdk.dto.error import APIErrorModel, HTTPValidationError, ValidationError from asknews_sdk.dto.news import ( @@ -55,6 +57,8 @@ "DomainMetricsDayItem", "DomainMetricsResponse", "DomainMetricsTimeWindowResponse", + "TopNArticlesForDomainItem", + "TopNArticlesForDomainResponse", "APIErrorModel", "ValidationError", "HTTPValidationError", diff --git a/asknews_sdk/dto/alert.py b/asknews_sdk/dto/alert.py index cfda476..a2b3d62 100644 --- a/asknews_sdk/dto/alert.py +++ b/asknews_sdk/dto/alert.py @@ -40,6 +40,30 @@ "claude-opus-4-8", "claude-opus-5-5", "gpt-5.6-terra", + "o1-mini", + "o1", + "gpt-4o-mini", + "gpt-4o", + "gpt-4.1-2025-04-14", + "gpt-5-mini", + "command-nightly", + "claude-3-5-sonnet-20240620", + "claude-3-5-sonnet-latest", + "claude-3-7-sonnet-20250219", + "claude-3-7-sonnet-nothinking", + "claude-opus-5", + "gpt-6-sol", + "gpt-6-astra", + "meta-llama/Meta-Llama-3-8B-Instruct", + "meta-llama/Meta-Llama-3.1-405B-Instruct", + "meta-llama/Meta-Llama-3.3-70B-Instruct", + "meta-llama/Llama-4-Maverick-17B-128E-Instruct", + "gemini-2.5-pro-preview-03-25", + "gemini-3.1-pro", + "gemini-3.5-flash", + "gemini-3.6-flash", + "kimi-k2p5", + "kimi-k3", ] @@ -77,6 +101,7 @@ "claude-opus-4-8", "gpt-5.6-terra" "meta-llama/Meta-Llama-3.1-405B-Instruct", "meta-llama/Meta-Llama-3.3-70B-Instruct", + "gpt-6-astra", ] AlertReportModelDefault: AlertReportModel = "claude-sonnet-4-6" diff --git a/asknews_sdk/dto/distribution.py b/asknews_sdk/dto/distribution.py index 1edd58f..9dfc549 100644 --- a/asknews_sdk/dto/distribution.py +++ b/asknews_sdk/dto/distribution.py @@ -1,4 +1,4 @@ -from typing import List +from typing import List, Optional from pydantic import BaseModel @@ -23,3 +23,16 @@ class DomainMetricsTimeWindowResponse(BaseSchema): total_surfaces: int total_citations: int total_full_text: int + + +class TopNArticlesForDomainItem(BaseModel): + article_url: str + article_id: Optional[str] = None + hit_count: int + + +class TopNArticlesForDomainResponse(BaseSchema): + data: List[TopNArticlesForDomainItem] + total_count: int + page: int = 1 + next_page: Optional[int] = None diff --git a/tests/api/test_chat.py b/tests/api/test_chat.py index 6b33d6e..3d871a9 100644 --- a/tests/api/test_chat.py +++ b/tests/api/test_chat.py @@ -15,7 +15,7 @@ ) from asknews_sdk.dto.deepnews import CreateDeepNewsResponse from asknews_sdk.utils import build_accept_header -from tests.api.test_chat_model_types import ADVANCED_DEEPNEWS_MODELS +from tests.api.test_chat_model_types import PRODUCTION_DEEPNEWS_MODELS class MockCreateChatCompletionResponse(ModelFactory[CreateChatCompletionResponse]): @@ -48,7 +48,7 @@ def async_chat_api(async_api_client: AsyncAPIClient): return AsyncChatAPI(async_api_client) -@pytest.mark.parametrize("model", ADVANCED_DEEPNEWS_MODELS) +@pytest.mark.parametrize("model", PRODUCTION_DEEPNEWS_MODELS) def test_sync_deepnews_model_request(sync_chat_api: ChatAPI, response_mock: MockRouter, model): mock_response = MockCreateDeepNewsResponse.build(model=model) route = response_mock.post("/v1/chat/deepnews").respond( @@ -63,7 +63,7 @@ def test_sync_deepnews_model_request(sync_chat_api: ChatAPI, response_mock: Mock assert json.loads(route.calls.last.request.content)["model"] == model -@pytest.mark.parametrize("model", ADVANCED_DEEPNEWS_MODELS) +@pytest.mark.parametrize("model", PRODUCTION_DEEPNEWS_MODELS) async def test_async_deepnews_model_request( async_chat_api: AsyncChatAPI, response_mock: MockRouter, model ): diff --git a/tests/api/test_chat_model_types.py b/tests/api/test_chat_model_types.py index cb6aad7..0d8b6b9 100644 --- a/tests/api/test_chat_model_types.py +++ b/tests/api/test_chat_model_types.py @@ -1,9 +1,9 @@ import json -from inspect import getmembers, isclass +from pathlib import Path from typing import get_args, get_type_hints import pytest -from pydantic import BaseModel, TypeAdapter, ValidationError +from pydantic import TypeAdapter, ValidationError from asknews_sdk.api.chat import AsyncChatAPI, ChatAPI, ChatModel, DeepNewsModel from asknews_sdk.dto import alert as alert_dto @@ -13,10 +13,14 @@ GPT_6_ASTRA = "gpt-6-astra" CLAUDE_FABLE_MODELS = ("claude-fable-5", "claude-fable-5-1") NEW_DEEPNEWS_MODELS = ("claude-sonnet-5-5", "claude-opus-5-5", "gpt-6.1-sol") -ADVANCED_DEEPNEWS_MODELS = (*NEW_DEEPNEWS_MODELS, "gpt-6-sol") +ADVANCED_DEEPNEWS_MODELS = (*NEW_DEEPNEWS_MODELS, "gpt-6-sol", "kimi-k3") +PRODUCTION_CONTRACT = json.loads( + (Path(__file__).resolve().parents[1] / "fixtures" / "production_contract_0323.json").read_text() +) +PRODUCTION_DEEPNEWS_MODELS = PRODUCTION_CONTRACT["deepnews_models"] -def test_gpt_6_astra_is_a_direct_deepnews_model_only(): +def test_gpt_6_astra_is_a_deepnews_model_not_a_chat_model(): assert GPT_6_ASTRA in get_args(DeepNewsModel) assert GPT_6_ASTRA not in get_args(ChatModel) @@ -30,18 +34,11 @@ def test_gpt_6_astra_is_scoped_to_get_deep_news(api_type): assert GPT_6_ASTRA not in get_args(forecast_model) -def test_gpt_6_astra_is_absent_from_alert_model_contracts(): - alert_model_types = ( - alert_dto.DeepNewsModel, - alert_dto.CheckAlertModel, - alert_dto.AlertReportModel, - ) - for model_type in alert_model_types: - assert GPT_6_ASTRA not in get_args(model_type) - - for name, model in getmembers(alert_dto, isclass): - if model.__module__ == alert_dto.__name__ and issubclass(model, BaseModel): - assert GPT_6_ASTRA not in json.dumps(model.model_json_schema()), name +def test_gpt_6_astra_matches_updated_alert_contracts(): + assert GPT_6_ASTRA in get_args(alert_dto.DeepNewsModel) + assert GPT_6_ASTRA in get_args(alert_dto.AlertReportModel) + assert GPT_6_ASTRA not in get_args(alert_dto.CheckAlertModel) + assert alert_dto.LegacyReportRequest(model=GPT_6_ASTRA).model == GPT_6_ASTRA @pytest.mark.parametrize("model", ADVANCED_DEEPNEWS_MODELS) @@ -61,7 +58,7 @@ def test_advanced_deepnews_models_serialize_in_requests(model): assert request.model_dump(mode="json")["model"] == model -@pytest.mark.parametrize("model", NEW_DEEPNEWS_MODELS) +@pytest.mark.parametrize("model", PRODUCTION_DEEPNEWS_MODELS) @pytest.mark.parametrize( "request_type", [alert_dto.CreateAlertRequest, alert_dto.UpdateAlertRequest] ) @@ -96,3 +93,28 @@ def test_claude_fable_models_match_deepnews_and_alert_report_contracts(model): assert model in get_args(DeepNewsModel) assert model in get_args(alert_dto.DeepNewsModel) assert model in get_args(alert_dto.AlertReportModel) + + +@pytest.mark.parametrize("model_type", [DeepNewsModel, alert_dto.DeepNewsModel]) +def test_deepnews_catalog_matches_production_schema(model_type): + assert set(get_args(model_type)) == set(PRODUCTION_DEEPNEWS_MODELS) + + +@pytest.mark.parametrize( + "request_type", [alert_dto.CreateAlertRequest, alert_dto.UpdateAlertRequest] +) +def test_new_legacy_report_option_serializes(request_type): + request = request_type( + cron="0 9 * * *", + triggers=[], + sources=[], + report={"identifier": "legacy", "model": GPT_6_ASTRA}, + ) + assert json.loads(request.model_dump_json())["report"]["model"] == GPT_6_ASTRA + + +def test_new_legacy_report_option_is_authoritative_and_defaults_are_preserved(): + assert GPT_6_ASTRA in PRODUCTION_CONTRACT["legacy_report_models"] + assert alert_dto.LegacyReportRequest().model == "claude-sonnet-4-6" + assert alert_dto.DeepNewsSourceParams().engine == "v1" + assert alert_dto.DeepNewsReportParams().engine == "v1" diff --git a/tests/api/test_distribution.py b/tests/api/test_distribution.py index 606086e..3117f10 100644 --- a/tests/api/test_distribution.py +++ b/tests/api/test_distribution.py @@ -1,3 +1,5 @@ +from inspect import signature +from typing import get_args, get_type_hints from urllib.parse import parse_qs import pytest @@ -5,7 +7,11 @@ from asknews_sdk.api.distribution import AsyncDistributionAPI, DistributionAPI from asknews_sdk.client import APIClient, AsyncAPIClient -from asknews_sdk.dto.distribution import DomainMetricsResponse, DomainMetricsTimeWindowResponse +from asknews_sdk.dto.distribution import ( + DomainMetricsResponse, + DomainMetricsTimeWindowResponse, + TopNArticlesForDomainResponse, +) from asknews_sdk.sdk import AskNewsSDK, AsyncAskNewsSDK @@ -119,3 +125,110 @@ async def test_async_get_domain_metrics_timeseries( assert parse_qs(mock_route.calls.last.request.url.query.decode()) == { "domain_names": DOMAIN_NAMES } + + +TOP_N_PAYLOAD = { + "data": [{"article_url": "https://example.com/news", "hit_count": 12}], + "total_count": 1, +} + + +@pytest.mark.parametrize("metric", ["surface", "citation", "grounded"]) +def test_sync_top_n_articles_serialization(sync_distribution_api, response_mock, metric): + route = response_mock.get("/v1/distribution/articles/top_n_for_domains").respond( + json=TOP_N_PAYLOAD + ) + response = sync_distribution_api.get_top_n_articles_for_domains( + DOMAIN_NAMES, + limit=25, + page=2, + start_date=START_DATE, + end_date=END_DATE, + metric=metric, + http_headers={"custom-header": "custom-value"}, + ) + request = route.calls.last.request + assert request.method == "GET" + assert request.headers["accept"] == TopNArticlesForDomainResponse.__content_type__ + assert request.headers["custom-header"] == "custom-value" + assert parse_qs(request.url.query.decode()) == { + "domain_names": DOMAIN_NAMES, + "limit": ["25"], + "page": ["2"], + "start_date": [str(START_DATE)], + "end_date": [str(END_DATE)], + "metric": [metric], + } + assert response == TopNArticlesForDomainResponse(**TOP_N_PAYLOAD) + assert response.data[0].article_id is None + assert response.page == 1 + assert response.next_page is None + + +@pytest.mark.parametrize("metric", ["surface", "citation", "grounded"]) +async def test_async_top_n_articles_serialization(async_distribution_api, response_mock, metric): + payload = { + "data": [ + {"article_url": "https://example.com/news", "article_id": "article-1", "hit_count": 5} + ], + "total_count": 3, + "page": 2, + "next_page": 3, + } + route = response_mock.get("/v1/distribution/articles/top_n_for_domains").respond(json=payload) + response = await async_distribution_api.get_top_n_articles_for_domains( + DOMAIN_NAMES, + limit=1, + page=2, + start_date=START_DATE, + end_date=END_DATE, + metric=metric, + http_headers={"custom-header": "custom-value"}, + ) + request = route.calls.last.request + assert request.method == "GET" + assert request.headers["accept"] == TopNArticlesForDomainResponse.__content_type__ + assert request.headers["custom-header"] == "custom-value" + assert parse_qs(request.url.query.decode()) == { + "domain_names": DOMAIN_NAMES, + "limit": ["1"], + "page": ["2"], + "start_date": [str(START_DATE)], + "end_date": [str(END_DATE)], + "metric": [metric], + } + assert response == TopNArticlesForDomainResponse(**payload) + + +@pytest.mark.parametrize("use_async", [False, True]) +async def test_top_n_articles_defaults( + sync_distribution_api, async_distribution_api, response_mock, use_async +): + payload = {"data": [], "total_count": 0, "next_page": None} + route = response_mock.get("/v1/distribution/articles/top_n_for_domains").respond(json=payload) + if use_async: + response = await async_distribution_api.get_top_n_articles_for_domains(DOMAIN_NAMES) + else: + response = sync_distribution_api.get_top_n_articles_for_domains(DOMAIN_NAMES) + assert response.data == [] + assert parse_qs(route.calls.last.request.url.query.decode()) == { + "domain_names": DOMAIN_NAMES, + "limit": ["10"], + "page": ["1"], + "metric": ["surface"], + } + + +@pytest.mark.parametrize("api_type", [DistributionAPI, AsyncDistributionAPI]) +def test_top_n_signature_matches_production_contract(api_type): + from tests.api.test_chat_model_types import PRODUCTION_CONTRACT + + method = api_type.get_top_n_articles_for_domains + params = signature(method).parameters + assert set(params) - {"self", "http_headers"} == { + param["name"] for param in PRODUCTION_CONTRACT["top_n_parameters"] + } + for param in PRODUCTION_CONTRACT["top_n_parameters"]: + if not param["required"]: + assert params[param["name"]].default == param["schema"].get("default") + assert get_args(get_type_hints(method)["metric"]) == ("surface", "citation", "grounded") diff --git a/tests/fixtures/production_contract_0323.json b/tests/fixtures/production_contract_0323.json new file mode 100644 index 0000000..25fcc7c --- /dev/null +++ b/tests/fixtures/production_contract_0323.json @@ -0,0 +1,179 @@ +{ + "source": "https://api.asknews.app/openapi.json", + "fetched_at": "2026-10-05T23:02:53Z", + "version": "0.32.3", + "raw_sha256": "4d7eeec4435f4cfaf8bce613994a9ab9c201695fdc15df74133aeb76a46a430e", + "deepnews_models": [ + "o1-mini", + "o1", + "o3-mini", + "o3", + "gpt-4o-mini", + "gpt-4o", + "gpt-4.1-2025-04-14", + "gpt-5-mini", + "gpt-5", + "command-nightly", + "claude-3-5-sonnet-20240620", + "claude-3-5-sonnet-latest", + "claude-3-7-sonnet-latest", + "claude-3-7-sonnet-20250219", + "claude-3-7-sonnet-nothinking", + "claude-sonnet-4-20250514", + "claude-opus-4-20250514", + "claude-sonnet-4-5-20250929", + "claude-opus-4-5-20251101", + "claude-sonnet-4-6", + "claude-sonnet-5", + "claude-sonnet-5-5", + "claude-opus-4-8", + "claude-opus-5", + "claude-opus-5-5", + "claude-opus-4-6", + "claude-fable-5", + "claude-fable-5-1", + "gpt-5.6-terra", + "gpt-5.6-sol", + "gpt-6-sol", + "gpt-6.1-sol", + "gpt-6-astra", + "deepseek", + "deepseek-basic", + "deepseek-r1-0528", + "meta-llama/Meta-Llama-3-8B-Instruct", + "meta-llama/Meta-Llama-3.1-405B-Instruct", + "meta-llama/Meta-Llama-3.3-70B-Instruct", + "meta-llama/Llama-4-Maverick-17B-128E-Instruct", + "gemini-2.5-flash", + "gemini-2.5-pro", + "gemini-2.5-pro-preview-03-25", + "gemini-3-pro", + "gemini-3.1-pro", + "gemini-3-flash", + "gemini-3.5-flash", + "gemini-3.6-flash", + "open-source-best", + "kimi-k2p5", + "kimi-k3" + ], + "legacy_report_models": [ + "gpt-5", + "gpt-4o", + "gpt-4.1-2025-04-14", + "gpt-4.1-mini-2025-04-14", + "gpt-4o-mini", + "o3-mini", + "claude-3-5-sonnet-latest", + "claude-sonnet-4-20250514", + "claude-sonnet-4-5-20250929", + "claude-opus-4-5-20251101", + "claude-opus-4-6", + "claude-fable-5", + "claude-fable-5-1", + "claude-sonnet-4-6", + "claude-sonnet-5", + "claude-opus-4-8", + "claude-opus-5", + "gpt-5.6-terra", + "gpt-5.6-sol", + "gpt-6-astra", + "meta-llama/Meta-Llama-3.1-405B-Instruct", + "meta-llama/Meta-Llama-3.3-70B-Instruct" + ], + "top_n_parameters": [ + { + "name": "domain_names", + "in": "query", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Domain names to filter by", + "title": "Domain Names" + }, + "description": "Domain names to filter by" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 1, + "description": "Number of top domain articles to return (page size)", + "default": 10, + "title": "Limit" + }, + "description": "Number of top domain articles to return (page size)" + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "description": "Page number (1-based; page size = limit)", + "default": 1, + "title": "Page" + }, + "description": "Page number (1-based; page size = limit)" + }, + { + "name": "start_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Start date to filter by (timestamp in seconds since epoch)", + "title": "Start Date" + }, + "description": "Start date to filter by (timestamp in seconds since epoch)" + }, + { + "name": "end_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "End date to filter by (timestamp in seconds since epoch)", + "title": "End Date" + }, + "description": "End date to filter by (timestamp in seconds since epoch)" + }, + { + "name": "metric", + "in": "query", + "required": false, + "schema": { + "enum": [ + "surface", + "citation", + "grounded" + ], + "type": "string", + "description": "Metric used to rank and count the returned articles", + "default": "surface", + "title": "Metric" + }, + "description": "Metric used to rank and count the returned articles" + } + ] +} From e15f3135747882144da6c3e1c8de1f7d2bef506c Mon Sep 17 00:00:00 2001 From: Wagner Costa Date: Tue, 6 Oct 2026 16:22:44 -0300 Subject: [PATCH 3/3] fix: Limit SDK update to the requested DeepNews models --- asknews_sdk/api/chat.py | 24 --- asknews_sdk/api/distribution.py | 80 +-------- asknews_sdk/dto/__init__.py | 4 - asknews_sdk/dto/alert.py | 28 +-- asknews_sdk/dto/distribution.py | 15 +- tests/api/test_chat.py | 6 +- tests/api/test_chat_model_types.py | 57 +++--- tests/api/test_distribution.py | 115 +----------- tests/fixtures/production_contract_0323.json | 179 ------------------- 9 files changed, 29 insertions(+), 479 deletions(-) delete mode 100644 tests/fixtures/production_contract_0323.json diff --git a/asknews_sdk/api/chat.py b/asknews_sdk/api/chat.py index 621b940..de342b4 100644 --- a/asknews_sdk/api/chat.py +++ b/asknews_sdk/api/chat.py @@ -77,30 +77,6 @@ "claude-opus-4-8", "claude-opus-5-5", "gpt-5.6-terra", - "o1-mini", - "o1", - "gpt-4o-mini", - "gpt-4o", - "gpt-4.1-2025-04-14", - "gpt-5-mini", - "command-nightly", - "claude-3-5-sonnet-20240620", - "claude-3-5-sonnet-latest", - "claude-3-7-sonnet-20250219", - "claude-3-7-sonnet-nothinking", - "claude-opus-5", - "meta-llama/Meta-Llama-3-8B-Instruct", - "meta-llama/Meta-Llama-3.1-405B-Instruct", - "meta-llama/Meta-Llama-3.3-70B-Instruct", - "meta-llama/Llama-4-Maverick-17B-128E-Instruct", - "gemini-2.5-pro-preview-03-25", - "gemini-3.1-pro", - "gemini-3-flash", - "gemini-3.5-flash", - "gemini-3.6-flash", - "open-source-best", - "kimi-k2p5", - "kimi-k3", ] diff --git a/asknews_sdk/api/distribution.py b/asknews_sdk/api/distribution.py index b784412..abc8654 100644 --- a/asknews_sdk/api/distribution.py +++ b/asknews_sdk/api/distribution.py @@ -1,12 +1,8 @@ -from typing import Dict, List, Literal, Optional +from typing import Dict, List, Optional from asknews_sdk.api.base import BaseAPI from asknews_sdk.client import APIClient, AsyncAPIClient -from asknews_sdk.dto.distribution import ( - DomainMetricsResponse, - DomainMetricsTimeWindowResponse, - TopNArticlesForDomainResponse, -) +from asknews_sdk.dto.distribution import DomainMetricsResponse, DomainMetricsTimeWindowResponse class DistributionAPI(BaseAPI[APIClient]): @@ -56,42 +52,6 @@ def get_domain_metrics_timeseries( ) return DomainMetricsTimeWindowResponse.model_validate(response.content) - def get_top_n_articles_for_domains( - self, - domain_names: List[str], - limit: int = 10, - page: int = 1, - start_date: Optional[int] = None, - end_date: Optional[int] = None, - metric: Literal["surface", "citation", "grounded"] = "surface", - *, - http_headers: Optional[Dict] = None, - ) -> TopNArticlesForDomainResponse: - """Get top domain articles ranked by raw counts for the selected metric. - - :param domain_names: Domain names to filter by. - :param limit: Page size (1–100), defaults to 10. - :param page: Page number (1-based), defaults to 1. - :param start_date: Start timestamp in seconds since epoch. - :param end_date: End timestamp in seconds since epoch. - :param metric: Ranking metric: surface, citation, or grounded. - """ - response = self.client.request( - method="GET", - endpoint="/v1/distribution/articles/top_n_for_domains", - query={ - "domain_names": domain_names, - "limit": limit, - "page": page, - "start_date": start_date, - "end_date": end_date, - "metric": metric, - }, - headers=http_headers, - accept=[(TopNArticlesForDomainResponse.__content_type__, 1.0)], - ) - return TopNArticlesForDomainResponse.model_validate(response.content) - class AsyncDistributionAPI(BaseAPI[AsyncAPIClient]): """Distribution API (async).""" @@ -139,39 +99,3 @@ async def get_domain_metrics_timeseries( accept=[(DomainMetricsTimeWindowResponse.__content_type__, 1.0)], ) return DomainMetricsTimeWindowResponse.model_validate(response.content) - - async def get_top_n_articles_for_domains( - self, - domain_names: List[str], - limit: int = 10, - page: int = 1, - start_date: Optional[int] = None, - end_date: Optional[int] = None, - metric: Literal["surface", "citation", "grounded"] = "surface", - *, - http_headers: Optional[Dict] = None, - ) -> TopNArticlesForDomainResponse: - """Get top domain articles ranked by raw counts for the selected metric. - - :param domain_names: Domain names to filter by. - :param limit: Page size (1–100), defaults to 10. - :param page: Page number (1-based), defaults to 1. - :param start_date: Start timestamp in seconds since epoch. - :param end_date: End timestamp in seconds since epoch. - :param metric: Ranking metric: surface, citation, or grounded. - """ - response = await self.client.request( - method="GET", - endpoint="/v1/distribution/articles/top_n_for_domains", - query={ - "domain_names": domain_names, - "limit": limit, - "page": page, - "start_date": start_date, - "end_date": end_date, - "metric": metric, - }, - headers=http_headers, - accept=[(TopNArticlesForDomainResponse.__content_type__, 1.0)], - ) - return TopNArticlesForDomainResponse.model_validate(response.content) diff --git a/asknews_sdk/dto/__init__.py b/asknews_sdk/dto/__init__.py index ec071b5..4187618 100644 --- a/asknews_sdk/dto/__init__.py +++ b/asknews_sdk/dto/__init__.py @@ -16,8 +16,6 @@ DomainMetricsDayItem, DomainMetricsResponse, DomainMetricsTimeWindowResponse, - TopNArticlesForDomainItem, - TopNArticlesForDomainResponse, ) from asknews_sdk.dto.error import APIErrorModel, HTTPValidationError, ValidationError from asknews_sdk.dto.news import ( @@ -57,8 +55,6 @@ "DomainMetricsDayItem", "DomainMetricsResponse", "DomainMetricsTimeWindowResponse", - "TopNArticlesForDomainItem", - "TopNArticlesForDomainResponse", "APIErrorModel", "ValidationError", "HTTPValidationError", diff --git a/asknews_sdk/dto/alert.py b/asknews_sdk/dto/alert.py index a2b3d62..03cb274 100644 --- a/asknews_sdk/dto/alert.py +++ b/asknews_sdk/dto/alert.py @@ -40,30 +40,6 @@ "claude-opus-4-8", "claude-opus-5-5", "gpt-5.6-terra", - "o1-mini", - "o1", - "gpt-4o-mini", - "gpt-4o", - "gpt-4.1-2025-04-14", - "gpt-5-mini", - "command-nightly", - "claude-3-5-sonnet-20240620", - "claude-3-5-sonnet-latest", - "claude-3-7-sonnet-20250219", - "claude-3-7-sonnet-nothinking", - "claude-opus-5", - "gpt-6-sol", - "gpt-6-astra", - "meta-llama/Meta-Llama-3-8B-Instruct", - "meta-llama/Meta-Llama-3.1-405B-Instruct", - "meta-llama/Meta-Llama-3.3-70B-Instruct", - "meta-llama/Llama-4-Maverick-17B-128E-Instruct", - "gemini-2.5-pro-preview-03-25", - "gemini-3.1-pro", - "gemini-3.5-flash", - "gemini-3.6-flash", - "kimi-k2p5", - "kimi-k3", ] @@ -99,9 +75,9 @@ "claude-sonnet-5", "gpt-5.6-sol", "claude-opus-4-8", - "gpt-5.6-terra" "meta-llama/Meta-Llama-3.1-405B-Instruct", + "gpt-5.6-terra", + "meta-llama/Meta-Llama-3.1-405B-Instruct", "meta-llama/Meta-Llama-3.3-70B-Instruct", - "gpt-6-astra", ] AlertReportModelDefault: AlertReportModel = "claude-sonnet-4-6" diff --git a/asknews_sdk/dto/distribution.py b/asknews_sdk/dto/distribution.py index 9dfc549..1edd58f 100644 --- a/asknews_sdk/dto/distribution.py +++ b/asknews_sdk/dto/distribution.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import List from pydantic import BaseModel @@ -23,16 +23,3 @@ class DomainMetricsTimeWindowResponse(BaseSchema): total_surfaces: int total_citations: int total_full_text: int - - -class TopNArticlesForDomainItem(BaseModel): - article_url: str - article_id: Optional[str] = None - hit_count: int - - -class TopNArticlesForDomainResponse(BaseSchema): - data: List[TopNArticlesForDomainItem] - total_count: int - page: int = 1 - next_page: Optional[int] = None diff --git a/tests/api/test_chat.py b/tests/api/test_chat.py index 3d871a9..6b33d6e 100644 --- a/tests/api/test_chat.py +++ b/tests/api/test_chat.py @@ -15,7 +15,7 @@ ) from asknews_sdk.dto.deepnews import CreateDeepNewsResponse from asknews_sdk.utils import build_accept_header -from tests.api.test_chat_model_types import PRODUCTION_DEEPNEWS_MODELS +from tests.api.test_chat_model_types import ADVANCED_DEEPNEWS_MODELS class MockCreateChatCompletionResponse(ModelFactory[CreateChatCompletionResponse]): @@ -48,7 +48,7 @@ def async_chat_api(async_api_client: AsyncAPIClient): return AsyncChatAPI(async_api_client) -@pytest.mark.parametrize("model", PRODUCTION_DEEPNEWS_MODELS) +@pytest.mark.parametrize("model", ADVANCED_DEEPNEWS_MODELS) def test_sync_deepnews_model_request(sync_chat_api: ChatAPI, response_mock: MockRouter, model): mock_response = MockCreateDeepNewsResponse.build(model=model) route = response_mock.post("/v1/chat/deepnews").respond( @@ -63,7 +63,7 @@ def test_sync_deepnews_model_request(sync_chat_api: ChatAPI, response_mock: Mock assert json.loads(route.calls.last.request.content)["model"] == model -@pytest.mark.parametrize("model", PRODUCTION_DEEPNEWS_MODELS) +@pytest.mark.parametrize("model", ADVANCED_DEEPNEWS_MODELS) async def test_async_deepnews_model_request( async_chat_api: AsyncChatAPI, response_mock: MockRouter, model ): diff --git a/tests/api/test_chat_model_types.py b/tests/api/test_chat_model_types.py index 0d8b6b9..9584cb1 100644 --- a/tests/api/test_chat_model_types.py +++ b/tests/api/test_chat_model_types.py @@ -1,9 +1,9 @@ import json -from pathlib import Path +from inspect import getmembers, isclass from typing import get_args, get_type_hints import pytest -from pydantic import TypeAdapter, ValidationError +from pydantic import BaseModel, TypeAdapter, ValidationError from asknews_sdk.api.chat import AsyncChatAPI, ChatAPI, ChatModel, DeepNewsModel from asknews_sdk.dto import alert as alert_dto @@ -13,14 +13,10 @@ GPT_6_ASTRA = "gpt-6-astra" CLAUDE_FABLE_MODELS = ("claude-fable-5", "claude-fable-5-1") NEW_DEEPNEWS_MODELS = ("claude-sonnet-5-5", "claude-opus-5-5", "gpt-6.1-sol") -ADVANCED_DEEPNEWS_MODELS = (*NEW_DEEPNEWS_MODELS, "gpt-6-sol", "kimi-k3") -PRODUCTION_CONTRACT = json.loads( - (Path(__file__).resolve().parents[1] / "fixtures" / "production_contract_0323.json").read_text() -) -PRODUCTION_DEEPNEWS_MODELS = PRODUCTION_CONTRACT["deepnews_models"] +ADVANCED_DEEPNEWS_MODELS = (*NEW_DEEPNEWS_MODELS, "gpt-6-sol") -def test_gpt_6_astra_is_a_deepnews_model_not_a_chat_model(): +def test_gpt_6_astra_is_a_direct_deepnews_model_only(): assert GPT_6_ASTRA in get_args(DeepNewsModel) assert GPT_6_ASTRA not in get_args(ChatModel) @@ -34,11 +30,18 @@ def test_gpt_6_astra_is_scoped_to_get_deep_news(api_type): assert GPT_6_ASTRA not in get_args(forecast_model) -def test_gpt_6_astra_matches_updated_alert_contracts(): - assert GPT_6_ASTRA in get_args(alert_dto.DeepNewsModel) - assert GPT_6_ASTRA in get_args(alert_dto.AlertReportModel) - assert GPT_6_ASTRA not in get_args(alert_dto.CheckAlertModel) - assert alert_dto.LegacyReportRequest(model=GPT_6_ASTRA).model == GPT_6_ASTRA +def test_gpt_6_astra_is_absent_from_alert_model_contracts(): + alert_model_types = ( + alert_dto.DeepNewsModel, + alert_dto.CheckAlertModel, + alert_dto.AlertReportModel, + ) + for model_type in alert_model_types: + assert GPT_6_ASTRA not in get_args(model_type) + + for name, model in getmembers(alert_dto, isclass): + if model.__module__ == alert_dto.__name__ and issubclass(model, BaseModel): + assert GPT_6_ASTRA not in json.dumps(model.model_json_schema()), name @pytest.mark.parametrize("model", ADVANCED_DEEPNEWS_MODELS) @@ -58,7 +61,7 @@ def test_advanced_deepnews_models_serialize_in_requests(model): assert request.model_dump(mode="json")["model"] == model -@pytest.mark.parametrize("model", PRODUCTION_DEEPNEWS_MODELS) +@pytest.mark.parametrize("model", NEW_DEEPNEWS_MODELS) @pytest.mark.parametrize( "request_type", [alert_dto.CreateAlertRequest, alert_dto.UpdateAlertRequest] ) @@ -95,26 +98,6 @@ def test_claude_fable_models_match_deepnews_and_alert_report_contracts(model): assert model in get_args(alert_dto.AlertReportModel) -@pytest.mark.parametrize("model_type", [DeepNewsModel, alert_dto.DeepNewsModel]) -def test_deepnews_catalog_matches_production_schema(model_type): - assert set(get_args(model_type)) == set(PRODUCTION_DEEPNEWS_MODELS) - - -@pytest.mark.parametrize( - "request_type", [alert_dto.CreateAlertRequest, alert_dto.UpdateAlertRequest] -) -def test_new_legacy_report_option_serializes(request_type): - request = request_type( - cron="0 9 * * *", - triggers=[], - sources=[], - report={"identifier": "legacy", "model": GPT_6_ASTRA}, - ) - assert json.loads(request.model_dump_json())["report"]["model"] == GPT_6_ASTRA - - -def test_new_legacy_report_option_is_authoritative_and_defaults_are_preserved(): - assert GPT_6_ASTRA in PRODUCTION_CONTRACT["legacy_report_models"] - assert alert_dto.LegacyReportRequest().model == "claude-sonnet-4-6" - assert alert_dto.DeepNewsSourceParams().engine == "v1" - assert alert_dto.DeepNewsReportParams().engine == "v1" +@pytest.mark.parametrize("model", ["gpt-5.6-terra", "meta-llama/Meta-Llama-3.1-405B-Instruct"]) +def test_alert_report_models_are_separate_literals(model): + assert model in get_args(alert_dto.AlertReportModel) diff --git a/tests/api/test_distribution.py b/tests/api/test_distribution.py index 3117f10..606086e 100644 --- a/tests/api/test_distribution.py +++ b/tests/api/test_distribution.py @@ -1,5 +1,3 @@ -from inspect import signature -from typing import get_args, get_type_hints from urllib.parse import parse_qs import pytest @@ -7,11 +5,7 @@ from asknews_sdk.api.distribution import AsyncDistributionAPI, DistributionAPI from asknews_sdk.client import APIClient, AsyncAPIClient -from asknews_sdk.dto.distribution import ( - DomainMetricsResponse, - DomainMetricsTimeWindowResponse, - TopNArticlesForDomainResponse, -) +from asknews_sdk.dto.distribution import DomainMetricsResponse, DomainMetricsTimeWindowResponse from asknews_sdk.sdk import AskNewsSDK, AsyncAskNewsSDK @@ -125,110 +119,3 @@ async def test_async_get_domain_metrics_timeseries( assert parse_qs(mock_route.calls.last.request.url.query.decode()) == { "domain_names": DOMAIN_NAMES } - - -TOP_N_PAYLOAD = { - "data": [{"article_url": "https://example.com/news", "hit_count": 12}], - "total_count": 1, -} - - -@pytest.mark.parametrize("metric", ["surface", "citation", "grounded"]) -def test_sync_top_n_articles_serialization(sync_distribution_api, response_mock, metric): - route = response_mock.get("/v1/distribution/articles/top_n_for_domains").respond( - json=TOP_N_PAYLOAD - ) - response = sync_distribution_api.get_top_n_articles_for_domains( - DOMAIN_NAMES, - limit=25, - page=2, - start_date=START_DATE, - end_date=END_DATE, - metric=metric, - http_headers={"custom-header": "custom-value"}, - ) - request = route.calls.last.request - assert request.method == "GET" - assert request.headers["accept"] == TopNArticlesForDomainResponse.__content_type__ - assert request.headers["custom-header"] == "custom-value" - assert parse_qs(request.url.query.decode()) == { - "domain_names": DOMAIN_NAMES, - "limit": ["25"], - "page": ["2"], - "start_date": [str(START_DATE)], - "end_date": [str(END_DATE)], - "metric": [metric], - } - assert response == TopNArticlesForDomainResponse(**TOP_N_PAYLOAD) - assert response.data[0].article_id is None - assert response.page == 1 - assert response.next_page is None - - -@pytest.mark.parametrize("metric", ["surface", "citation", "grounded"]) -async def test_async_top_n_articles_serialization(async_distribution_api, response_mock, metric): - payload = { - "data": [ - {"article_url": "https://example.com/news", "article_id": "article-1", "hit_count": 5} - ], - "total_count": 3, - "page": 2, - "next_page": 3, - } - route = response_mock.get("/v1/distribution/articles/top_n_for_domains").respond(json=payload) - response = await async_distribution_api.get_top_n_articles_for_domains( - DOMAIN_NAMES, - limit=1, - page=2, - start_date=START_DATE, - end_date=END_DATE, - metric=metric, - http_headers={"custom-header": "custom-value"}, - ) - request = route.calls.last.request - assert request.method == "GET" - assert request.headers["accept"] == TopNArticlesForDomainResponse.__content_type__ - assert request.headers["custom-header"] == "custom-value" - assert parse_qs(request.url.query.decode()) == { - "domain_names": DOMAIN_NAMES, - "limit": ["1"], - "page": ["2"], - "start_date": [str(START_DATE)], - "end_date": [str(END_DATE)], - "metric": [metric], - } - assert response == TopNArticlesForDomainResponse(**payload) - - -@pytest.mark.parametrize("use_async", [False, True]) -async def test_top_n_articles_defaults( - sync_distribution_api, async_distribution_api, response_mock, use_async -): - payload = {"data": [], "total_count": 0, "next_page": None} - route = response_mock.get("/v1/distribution/articles/top_n_for_domains").respond(json=payload) - if use_async: - response = await async_distribution_api.get_top_n_articles_for_domains(DOMAIN_NAMES) - else: - response = sync_distribution_api.get_top_n_articles_for_domains(DOMAIN_NAMES) - assert response.data == [] - assert parse_qs(route.calls.last.request.url.query.decode()) == { - "domain_names": DOMAIN_NAMES, - "limit": ["10"], - "page": ["1"], - "metric": ["surface"], - } - - -@pytest.mark.parametrize("api_type", [DistributionAPI, AsyncDistributionAPI]) -def test_top_n_signature_matches_production_contract(api_type): - from tests.api.test_chat_model_types import PRODUCTION_CONTRACT - - method = api_type.get_top_n_articles_for_domains - params = signature(method).parameters - assert set(params) - {"self", "http_headers"} == { - param["name"] for param in PRODUCTION_CONTRACT["top_n_parameters"] - } - for param in PRODUCTION_CONTRACT["top_n_parameters"]: - if not param["required"]: - assert params[param["name"]].default == param["schema"].get("default") - assert get_args(get_type_hints(method)["metric"]) == ("surface", "citation", "grounded") diff --git a/tests/fixtures/production_contract_0323.json b/tests/fixtures/production_contract_0323.json deleted file mode 100644 index 25fcc7c..0000000 --- a/tests/fixtures/production_contract_0323.json +++ /dev/null @@ -1,179 +0,0 @@ -{ - "source": "https://api.asknews.app/openapi.json", - "fetched_at": "2026-10-05T23:02:53Z", - "version": "0.32.3", - "raw_sha256": "4d7eeec4435f4cfaf8bce613994a9ab9c201695fdc15df74133aeb76a46a430e", - "deepnews_models": [ - "o1-mini", - "o1", - "o3-mini", - "o3", - "gpt-4o-mini", - "gpt-4o", - "gpt-4.1-2025-04-14", - "gpt-5-mini", - "gpt-5", - "command-nightly", - "claude-3-5-sonnet-20240620", - "claude-3-5-sonnet-latest", - "claude-3-7-sonnet-latest", - "claude-3-7-sonnet-20250219", - "claude-3-7-sonnet-nothinking", - "claude-sonnet-4-20250514", - "claude-opus-4-20250514", - "claude-sonnet-4-5-20250929", - "claude-opus-4-5-20251101", - "claude-sonnet-4-6", - "claude-sonnet-5", - "claude-sonnet-5-5", - "claude-opus-4-8", - "claude-opus-5", - "claude-opus-5-5", - "claude-opus-4-6", - "claude-fable-5", - "claude-fable-5-1", - "gpt-5.6-terra", - "gpt-5.6-sol", - "gpt-6-sol", - "gpt-6.1-sol", - "gpt-6-astra", - "deepseek", - "deepseek-basic", - "deepseek-r1-0528", - "meta-llama/Meta-Llama-3-8B-Instruct", - "meta-llama/Meta-Llama-3.1-405B-Instruct", - "meta-llama/Meta-Llama-3.3-70B-Instruct", - "meta-llama/Llama-4-Maverick-17B-128E-Instruct", - "gemini-2.5-flash", - "gemini-2.5-pro", - "gemini-2.5-pro-preview-03-25", - "gemini-3-pro", - "gemini-3.1-pro", - "gemini-3-flash", - "gemini-3.5-flash", - "gemini-3.6-flash", - "open-source-best", - "kimi-k2p5", - "kimi-k3" - ], - "legacy_report_models": [ - "gpt-5", - "gpt-4o", - "gpt-4.1-2025-04-14", - "gpt-4.1-mini-2025-04-14", - "gpt-4o-mini", - "o3-mini", - "claude-3-5-sonnet-latest", - "claude-sonnet-4-20250514", - "claude-sonnet-4-5-20250929", - "claude-opus-4-5-20251101", - "claude-opus-4-6", - "claude-fable-5", - "claude-fable-5-1", - "claude-sonnet-4-6", - "claude-sonnet-5", - "claude-opus-4-8", - "claude-opus-5", - "gpt-5.6-terra", - "gpt-5.6-sol", - "gpt-6-astra", - "meta-llama/Meta-Llama-3.1-405B-Instruct", - "meta-llama/Meta-Llama-3.3-70B-Instruct" - ], - "top_n_parameters": [ - { - "name": "domain_names", - "in": "query", - "required": true, - "schema": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Domain names to filter by", - "title": "Domain Names" - }, - "description": "Domain names to filter by" - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "maximum": 100, - "minimum": 1, - "description": "Number of top domain articles to return (page size)", - "default": 10, - "title": "Limit" - }, - "description": "Number of top domain articles to return (page size)" - }, - { - "name": "page", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "minimum": 1, - "description": "Page number (1-based; page size = limit)", - "default": 1, - "title": "Page" - }, - "description": "Page number (1-based; page size = limit)" - }, - { - "name": "start_date", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "description": "Start date to filter by (timestamp in seconds since epoch)", - "title": "Start Date" - }, - "description": "Start date to filter by (timestamp in seconds since epoch)" - }, - { - "name": "end_date", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "description": "End date to filter by (timestamp in seconds since epoch)", - "title": "End Date" - }, - "description": "End date to filter by (timestamp in seconds since epoch)" - }, - { - "name": "metric", - "in": "query", - "required": false, - "schema": { - "enum": [ - "surface", - "citation", - "grounded" - ], - "type": "string", - "description": "Metric used to rank and count the returned articles", - "default": "surface", - "title": "Metric" - }, - "description": "Metric used to rank and count the returned articles" - } - ] -}