diff --git a/.github/scripts/discover-validation-samples.py b/.github/scripts/discover-validation-samples.py new file mode 100644 index 000000000..1fd75dc12 --- /dev/null +++ b/.github/scripts/discover-validation-samples.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Discover the full sample inventory and emit deterministic validation matrices.""" + +from __future__ import annotations + +import argparse +import json +import stat +from pathlib import Path + +import yaml + +SUPPORTED_LANGUAGES = { + "csharp": "csharp", + "java": "java", + "python": "python", + "typescript": "typescript", + "javascript": "typescript", +} + + +class DiscoveryError(ValueError): + """A sample metadata error that should stop discovery.""" + + +def sample_id(path: str) -> str: + return path.removeprefix("samples/").replace("/", "-") + + +def metadata_path(root: Path, metadata: Path) -> str: + return metadata.relative_to(root).as_posix() + + +def validate_metadata_file(root: Path, metadata: Path) -> None: + path = metadata_path(root, metadata) + try: + resolved = metadata.resolve(strict=True) + mode = resolved.stat().st_mode + except OSError as exc: + raise DiscoveryError(f"{path}: could not inspect sample metadata: {exc}") from exc + + if not resolved.is_relative_to(root): + raise DiscoveryError(f"{path}: sample metadata resolves outside the repository root") + if not stat.S_ISREG(mode): + raise DiscoveryError(f"{path}: sample metadata is not a regular file") + + +def live_service_declaration(root: Path, metadata: Path) -> tuple[bool, str]: + path = metadata_path(root, metadata) + try: + contents = metadata.read_text(encoding="utf-8") + except (OSError, UnicodeError) as exc: + return False, f"{path}: could not read sample metadata: {exc}" + + try: + document = yaml.safe_load(contents) + except yaml.YAMLError as exc: + mark = getattr(exc, "problem_mark", None) + location = f" at line {mark.line + 1}, column {mark.column + 1}" if mark else "" + problem = getattr(exc, "problem", None) or str(exc) + return False, f"{path}: invalid YAML{location}: {problem}" + + if isinstance(document, dict) and "l4" in document: + raise DiscoveryError( + f"{path}: legacy top-level key 'l4' is unsupported; " + "rename it to 'live_service_validation'" + ) + return isinstance(document, dict) and "live_service_validation" in document, "" + + +def discover(root: Path) -> dict: + discovered = [] + paths_by_id = {} + for metadata in sorted(root.glob("samples/**/sample.yaml")): + validate_metadata_file(root, metadata) + path = metadata.parent.relative_to(root).as_posix() + identifier = sample_id(path) + if identifier in paths_by_id: + raise DiscoveryError( + f"{metadata_path(root, metadata)}: duplicate derived sample ID " + f"'{identifier}' also produced by {paths_by_id[identifier]}/sample.yaml" + ) + paths_by_id[identifier] = path + discovered.append((identifier, path, metadata)) + + samples = [] + for identifier, path, metadata in discovered: + language = path.split("/")[1] + validator_language = SUPPORTED_LANGUAGES.get(language) + live_service_validation_declared, metadata_error = live_service_declaration( + root, metadata + ) + sample = { + "id": identifier, + "path": path, + "language": language, + "shape": "full-fleet", + } + samples.append(sample) + sample["validator_language"] = validator_language or "" + sample["eligible"] = validator_language is not None and not metadata_error + sample["skip_reason"] = ( + metadata_error + or ( + "" + if validator_language + else f"language '{language}' is not supported by build readiness" + ) + ) + sample["live_service_validation_declared"] = live_service_validation_declared + + identities = [ + {key: sample[key] for key in ("id", "path", "language", "shape")} + for sample in samples + ] + return { + "schema_version": 2, + "samples": identities, + "validation": { + sample["id"]: { + key: sample[key] + for key in ( + "validator_language", + "eligible", + "skip_reason", + "live_service_validation_declared", + ) + } + for sample in samples + }, + "matrix": samples, + } + + +def write_matrix(path: Path, samples: list[dict]) -> None: + path.write_text( + json.dumps({"include": samples}, separators=(",", ":")), + encoding="utf-8", + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--root", type=Path, default=Path(".")) + parser.add_argument("--manifest", type=Path, required=True) + parser.add_argument("--matrix", type=Path, required=True) + parser.add_argument("--build-readiness-matrix", type=Path) + parser.add_argument("--live-service-matrix", type=Path) + args = parser.parse_args() + + try: + payload = discover(args.root.resolve()) + except DiscoveryError as exc: + parser.error(str(exc)) + args.manifest.write_text( + json.dumps( + {"schema_version": payload["schema_version"], "samples": payload["samples"], "validation": payload["validation"]}, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + write_matrix(args.matrix, payload["matrix"]) + if args.build_readiness_matrix: + write_matrix( + args.build_readiness_matrix, + [ + sample + for sample in payload["matrix"] + if not sample["live_service_validation_declared"] + ], + ) + if args.live_service_matrix: + write_matrix( + args.live_service_matrix, + [ + sample + for sample in payload["matrix"] + if sample["live_service_validation_declared"] + ], + ) + print(json.dumps({"count": len(payload["matrix"]), "matrix": payload["matrix"]}, separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/render-validation-report.py b/.github/scripts/render-validation-report.py new file mode 100644 index 000000000..75a5653b7 --- /dev/null +++ b/.github/scripts/render-validation-report.py @@ -0,0 +1,385 @@ +#!/usr/bin/env python3 +"""Render a run-scoped summary from validation-pilot result artifacts.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from urllib.parse import quote + +OUTCOMES = { + "passed": "✅ Passed", + "sample failure": "❌ Sample failure", + "infrastructure/error": "⚠️ Infrastructure/error", + "skipped/not-completed": "⏭️ Skipped/not-completed", +} +REQUIRED = { + "schema_version", "sample", "outcome", "completed_stage", "duration_seconds", + "diagnostic_reference", "artifact_reference", "completed_at", "run", +} +RUN_FIELDS = {"repository", "workflow", "run_id", "run_attempt", "sha", "ref", "started_at"} +SECTION_ORDER = ("sample failure", "infrastructure/error", "skipped/not-completed", "passed") +SECTION_LABELS = { + "sample failure": "Sample failures", + "infrastructure/error": "Infrastructure/errors", + "skipped/not-completed": "Skipped/not-completed", +} +DIAGNOSTIC_LIMIT = 240 +SUPPORTED_SCHEMA_VERSIONS = {1, 2} +LEGACY_COMPLETED_STAGES = { + "L3 validation": "build readiness validation", + "L3 validation invocation": "build readiness invocation", + "L4 validation": "live-service validation", + "L4 validation invocation": "live-service validation invocation", +} +DIAGNOSTIC_PATTERNS = ( + re.compile(r"(?:\berror\b|^FAIL:|^ERROR:|^SKIP:|^runner error:)", re.IGNORECASE), +) +VERDICT_PATTERN = re.compile(r"^verdict=", re.IGNORECASE) +REPOSITORY_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +SHA_PATTERN = re.compile(r"^[0-9a-f]{7,40}$") + + +class ContractError(ValueError): + pass + + +def load_json(path: Path, label: str) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise ContractError(f"{label} not found: {path}") from exc + except json.JSONDecodeError as exc: + raise ContractError(f"{label} is not valid JSON: {exc}") from exc + + +def timestamp(value: Any, field: str) -> datetime: + if not isinstance(value, str) or not value.endswith("Z"): + raise ContractError(f"{field} must be an ISO-8601 UTC timestamp ending in Z") + try: + parsed = datetime.fromisoformat(value[:-1] + "+00:00") + except ValueError as exc: + raise ContractError(f"{field} is not a valid timestamp") from exc + if parsed.utcoffset() != timezone.utc.utcoffset(parsed): + raise ContractError(f"{field} must be UTC") + return parsed + + +def sample_identity(value: Any, field: str) -> dict[str, str]: + keys = {"id", "path", "language", "shape"} + if not isinstance(value, dict) or set(value) != keys: + raise ContractError(f"{field} must contain exactly id, path, language, and shape") + if any(not isinstance(value[key], str) or not value[key] for key in keys): + raise ContractError(f"{field} fields must be non-empty strings") + if not value["path"].startswith("samples/") or ".." in Path(value["path"]).parts: + raise ContractError(f"{field}.path must be a safe repository-relative samples/ path") + return {key: value[key] for key in keys} + + +def load_expected(path: Path) -> list[dict[str, str]]: + payload = load_json(path, "sample manifest") + if ( + not isinstance(payload, dict) + or payload.get("schema_version") not in SUPPORTED_SCHEMA_VERSIONS + or not isinstance(payload.get("samples"), list) + or not payload["samples"] + ): + raise ContractError("sample manifest must contain a non-empty samples array") + samples = [sample_identity(value, "manifest sample") for value in payload["samples"]] + ids = [value["id"] for value in samples] + if ids != sorted(set(ids)): + raise ContractError("manifest samples must be sorted and unique by id") + return samples + + +def load_record(path: Path, expected: dict[str, str]) -> dict[str, Any]: + value = load_json(path, f"result artifact {path}") + if ( + not isinstance(value, dict) + or set(value) != REQUIRED + or value.get("schema_version") not in SUPPORTED_SCHEMA_VERSIONS + ): + raise ContractError("result must be a supported schema object") + missing = REQUIRED - value.keys() + if missing: + raise ContractError(f"result is missing fields: {sorted(missing)}") + sample = sample_identity(value["sample"], "result sample") + if sample != expected: + raise ContractError(f"sample identity does not match manifest: {sample['id']}") + if value["outcome"] not in OUTCOMES: + raise ContractError(f"unsupported outcome: {value['outcome']!r}") + if not isinstance(value["completed_stage"], str) or not value["completed_stage"]: + raise ContractError("completed_stage must be non-empty") + if not isinstance(value["duration_seconds"], (int, float)) or isinstance(value["duration_seconds"], bool) or value["duration_seconds"] < 0: + raise ContractError("duration_seconds must be non-negative") + timestamp(value["completed_at"], "completed_at") + run = value["run"] + if not isinstance(run, dict) or set(run) != RUN_FIELDS: + raise ContractError(f"run is missing fields: {sorted(RUN_FIELDS - set(run or {}))}") + timestamp(run["started_at"], "run.started_at") + for field in ("diagnostic_reference", "artifact_reference"): + reference = value[field] + if ( + not isinstance(reference, str) + or not reference + or Path(reference).is_absolute() + or ".." in Path(reference).parts + or len(Path(reference).parts) != 1 + ): + raise ContractError(f"{field} must be a relative filename") + diagnostic = path.parent / value["diagnostic_reference"] + if not diagnostic.is_file(): + raise ContractError(f"missing diagnostic: {diagnostic}") + completed_stage = LEGACY_COMPLETED_STAGES.get( + value["completed_stage"], value["completed_stage"] + ) + return { + **value, + "completed_stage": completed_stage, + "completed_at": timestamp(value["completed_at"], "completed_at"), + "diagnostic_path": diagnostic, + } + + +def collect(results_dir: Path, expected: list[dict[str, str]]) -> tuple[list[dict[str, Any]], bool]: + if not results_dir.is_dir(): + raise ContractError(f"result artifact directory not found: {results_dir}") + expected_by_id = {value["id"]: value for value in expected} + records: dict[str, dict[str, Any]] = {} + incomplete = False + for path in sorted(results_dir.glob("*/sample-result.json")): + try: + raw = load_json(path, f"result artifact {path}") + sample_id = raw.get("sample", {}).get("id") if isinstance(raw, dict) else None + if sample_id not in expected_by_id: + raise ContractError(f"unexpected sample id: {sample_id}") + if sample_id in records: + raise ContractError(f"duplicate result artifact for {sample_id}") + record = load_record(path, expected_by_id[sample_id]) + records[sample_id] = record + except ContractError as exc: + incomplete = True + records[f"invalid:{path}"] = { + "sample": {"id": path.name, "path": f"", "language": "reporting", "shape": "error"}, + "outcome": "infrastructure/error", "completed_stage": "reporting", + "duration_seconds": 0, "completed_at": None, + "diagnostic_reference": "—", "artifact_reference": path.name, "run": {}, + "error": str(exc), + } + for sample in expected: + if sample["id"] not in records: + incomplete = True + records[f"missing:{sample['id']}"] = { + "sample": sample, "outcome": "infrastructure/error", + "completed_stage": "reporting", "duration_seconds": 0, + "completed_at": None, "diagnostic_reference": "—", + "artifact_reference": "—", "run": {}, + "error": f"expected result artifact is missing for {sample['id']}", + } + return sorted(records.values(), key=lambda value: value["sample"]["path"]), incomplete + + +def markdown_cell(value: Any) -> str: + return ( + str(value) + .replace("\\", "\\\\") + .replace("|", "\\|") + .replace("`", "'") + .replace("\r", " ") + .replace("\n", " ") + ) + + +def sample_url(record: dict[str, Any]) -> str | None: + run = record.get("run", {}) + repository = run.get("repository") + sha = run.get("sha") + path = record["sample"]["path"] + if ( + not isinstance(repository, str) + or not REPOSITORY_PATTERN.fullmatch(repository) + or not isinstance(sha, str) + or not SHA_PATTERN.fullmatch(sha) + ): + return None + return f"https://github.com/{repository}/tree/{sha}/{quote(path, safe='/')}" + + +def diagnostic_excerpt(record: dict[str, Any]) -> str: + if record.get("error"): + value = record["error"] + else: + try: + lines = [ + line.strip() + for line in record["diagnostic_path"] + .read_text(encoding="utf-8", errors="replace") + .splitlines() + if line.strip() and not line.lstrip().startswith("$ ") + ] + except OSError: + return "No diagnostic excerpt available" + value = next( + ( + line + for line in lines + if any(pattern.search(line) for pattern in DIAGNOSTIC_PATTERNS) + ), + next( + (line for line in reversed(lines) if VERDICT_PATTERN.search(line)), + lines[0] if lines else "", + ), + ) + value = re.sub( + r"(?i)(token|secret|password|api[_ -]?key)(\s*[:=]\s*)\S+", + r"\1\2[redacted]", + value, + ) + value = re.sub(r"[\x00-\x1f\x7f]", " ", value) + value = " ".join(value.split()) + if len(value) > DIAGNOSTIC_LIMIT: + value = value[: DIAGNOSTIC_LIMIT - 1].rstrip() + "…" + return value or "No diagnostic excerpt available" + + +def render_sample(record: dict[str, Any]) -> str: + path = markdown_cell(record["sample"]["path"]) + url = sample_url(record) + return f"[`{path}`]({url})" if url else f"`{path}`" + + +def render_rows(records: list[dict[str, Any]], outcome: str) -> list[str]: + rows = [] + for record in records: + sample = render_sample(record) + stage = markdown_cell(record["completed_stage"]) + if outcome == "passed": + rows.append(f"| {sample} | {stage} | {record['duration_seconds']}s |") + continue + reason = markdown_cell(diagnostic_excerpt(record)) + if outcome == "skipped/not-completed": + next_action = "Add validator support or retain explicit skip" + elif outcome == "infrastructure/error": + next_action = "Inspect workflow job" + else: + next_action = "Inspect sample validation output" + rows.append(f"| {sample} | {stage} | `{reason}` | {next_action} |") + return rows + + +def render(records: list[dict[str, Any]], run_url: str | None, complete: bool) -> str: + counts = { + outcome: sum(record["outcome"] == outcome for record in records) + for outcome in OUTCOMES + } + action_required = counts["sample failure"] + counts["infrastructure/error"] + lines = ["## Validation report", ""] + run = next((record.get("run") for record in records if record.get("run")), {}) + if run: + metadata = f"Run {run.get('run_id')} · attempt {run.get('run_attempt')}" + repository = run.get("repository") + sha = run.get("sha") + if ( + isinstance(repository, str) + and REPOSITORY_PATTERN.fullmatch(repository) + and isinstance(sha, str) + and SHA_PATTERN.fullmatch(sha) + ): + metadata += ( + f" · validated SHA [`{sha[:12]}`]" + f"(https://github.com/{repository}/commit/{sha})" + ) + if run_url: + metadata += f" · [Workflow run]({run_url})" + lines.extend([f"_{metadata}_", ""]) + lines.extend( + [ + f"> **Action required:** {action_required} record(s) need maintainer attention", + f"> **Informational:** {counts['skipped/not-completed']} record(s) are intentionally skipped", + f"> **Fleet {'complete' if complete else 'incomplete'}:** {len(records)} result record(s) reported", + "", + f"**{len(records)} total · {counts['passed']} passed · " + f"{counts['sample failure']} sample failures · " + f"{counts['infrastructure/error']} infrastructure/errors · " + f"{counts['skipped/not-completed']} skipped/not-completed**", + "", + ] + ) + for outcome in SECTION_ORDER[:3]: + section_records = sorted( + (record for record in records if record["outcome"] == outcome), + key=lambda record: (record["sample"]["language"], record["sample"]["path"]), + ) + if not section_records: + continue + lines.extend( + [ + f"### {OUTCOMES[outcome].split(' ', 1)[0]} {SECTION_LABELS[outcome]} ({len(section_records)})", + "", + "| Sample | Stage | Reason | Next action |", + "|---|---|---|---|", + *render_rows(section_records, outcome), + "", + ] + ) + passed = sorted( + (record for record in records if record["outcome"] == "passed"), + key=lambda record: (record["sample"]["language"], record["sample"]["path"]), + ) + if passed: + lines.extend( + [ + f"
{OUTCOMES['passed']} ({len(passed)})", + "", + "| Sample | Stage | Duration |", + "|---|---|---:|", + *render_rows(passed, "passed"), + "", + "
", + "", + ] + ) + lines.extend( + [ + f"**Legend:** {OUTCOMES['passed']} · {OUTCOMES['sample failure']} · " + f"{OUTCOMES['infrastructure/error']} · {OUTCOMES['skipped/not-completed']}", + "", + "Diagnostics are summarized and sanitized. Full logs remain available from " + "the workflow run, subject to GitHub Actions retention and authentication.", + "", + ] + ) + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--results-dir", type=Path, required=True) + parser.add_argument("--expected-samples", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--run-url") + args = parser.parse_args() + try: + records, incomplete = collect(args.results_dir, load_expected(args.expected_samples)) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + render(records, args.run_url, not incomplete), + encoding="utf-8", + newline="\n", + ) + except (ContractError, OSError) as exc: + print(f"render-validation-report: {exc}", file=sys.stderr) + return 1 + if incomplete: + print("render-validation-report: incomplete result handoff", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/requirements.txt b/.github/scripts/requirements.txt new file mode 100644 index 000000000..f62ce0c56 --- /dev/null +++ b/.github/scripts/requirements.txt @@ -0,0 +1 @@ +PyYAML==6.0.3 diff --git a/.github/scripts/run-validation-pilot.py b/.github/scripts/run-validation-pilot.py new file mode 100644 index 000000000..d092f29b9 --- /dev/null +++ b/.github/scripts/run-validation-pilot.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Run one sample and write its canonical normalized result.""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +OUTCOMES = { + 0: "passed", + 1: "sample failure", + 2: "infrastructure/error", +} + + +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--sample-id", required=True) + parser.add_argument("--language", required=True) + parser.add_argument("--validator-language") + parser.add_argument("--shape", required=True) + parser.add_argument("--sample-path", required=True) + parser.add_argument("--validator", default=".github/scripts/validate-sample.sh") + parser.add_argument("--bash", default="bash") + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--diagnostic", type=Path, required=True) + parser.add_argument("--run-id", default=os.environ.get("GITHUB_RUN_ID", "local")) + parser.add_argument("--run-attempt", default=os.environ.get("GITHUB_RUN_ATTEMPT", "1")) + parser.add_argument("--sha", default=os.environ.get("GITHUB_SHA", "local")) + parser.add_argument("--ref", default=os.environ.get("GITHUB_REF", "local")) + parser.add_argument("--repository", default=os.environ.get("GITHUB_REPOSITORY", "local")) + parser.add_argument("--workflow", default=os.environ.get("GITHUB_WORKFLOW", "validation pilot")) + parser.add_argument("--run-live-service", action="store_true") + parser.add_argument("--skip-reason") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + started_at = utc_now() + started = time.monotonic() + validator_language = args.validator_language or args.language + if args.skip_reason: + diagnostic = f"skipped: {args.skip_reason}\n" + outcome = "skipped/not-completed" + stage = "inventory eligibility" + else: + command = [ + args.bash, + args.validator, + "--mode", + "build-readiness", + "--language", + validator_language, + "--sample-dir", + args.sample_path, + ] + try: + completed = subprocess.run(command, capture_output=True, text=True) + diagnostic = "$ " + " ".join(command) + "\n" + completed.stdout + completed.stderr + outcome = OUTCOMES.get(completed.returncode, "infrastructure/error") + stage = ( + "build readiness validation" + if completed.returncode in OUTCOMES + else "build readiness invocation" + ) + if outcome == "passed" and args.run_live_service: + live_service_command = [ + args.bash, + args.validator, + "--mode", + "live-service", + "--sample-dir", + args.sample_path, + ] + live_service = subprocess.run( + live_service_command, capture_output=True, text=True + ) + diagnostic += ( + "\n$ " + + " ".join(live_service_command) + + "\n" + + live_service.stdout + + live_service.stderr + ) + outcome = OUTCOMES.get( + live_service.returncode, "infrastructure/error" + ) + stage = ( + "live-service validation" + if live_service.returncode in OUTCOMES + else "live-service validation invocation" + ) + except (OSError, subprocess.SubprocessError) as exc: + diagnostic = "$ " + " ".join(command) + f"\nrunner error: {exc}\n" + outcome = "infrastructure/error" + stage = "build readiness invocation" + + completed_at = utc_now() + result = { + "schema_version": 2, + "sample": { + "id": args.sample_id, + "path": args.sample_path, + "language": args.language, + "shape": args.shape, + }, + "outcome": outcome, + "completed_stage": stage, + "duration_seconds": round(time.monotonic() - started, 3), + "diagnostic_reference": args.diagnostic.name, + "artifact_reference": args.output.name, + "completed_at": completed_at, + "run": { + "repository": args.repository, + "workflow": args.workflow, + "run_id": args.run_id, + "run_attempt": args.run_attempt, + "sha": args.sha, + "ref": args.ref, + "started_at": started_at, + }, + } + args.diagnostic.parent.mkdir(parents=True, exist_ok=True) + args.diagnostic.write_text(diagnostic, encoding="utf-8") + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(f"{args.sample_id}: {outcome} ({result['duration_seconds']}s)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/test/test-render-validation-report.py b/.github/scripts/test/test-render-validation-report.py new file mode 100644 index 000000000..f872f5d4a --- /dev/null +++ b/.github/scripts/test/test-render-validation-report.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).resolve().parents[1] / "render-validation-report.py" +SAMPLE_A = "samples/python/quickstart/a" +SAMPLE_B = "samples/csharp/quickstart/b" + + +class ReportTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.results = self.root / "results" + self.results.mkdir() + self.expected = self.root / "expected.json" + self.expected.write_text( + json.dumps( + { + "schema_version": 2, + "samples": [ + {"id": "a", "path": SAMPLE_A, "language": "python", "shape": "quickstart"}, + {"id": "b", "path": SAMPLE_B, "language": "csharp", "shape": "quickstart"}, + ] + } + ), + encoding="utf-8", + ) + self.output = self.root / "summary.md" + + def tearDown(self) -> None: + self.temp.cleanup() + + def run_report(self) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--results-dir", + str(self.results), + "--expected-samples", + str(self.expected), + "--output", + str(self.output), + "--run-url", + "https://github.com/example/repo/actions/runs/42", + ], + capture_output=True, + text=True, + ) + + def write_result( + self, + sample: str, + outcome: str = "passed", + diagnostic_text: str = "diagnostic\n", + completed_stage: str = "build readiness validation", + ) -> None: + manifest = json.loads(self.expected.read_text(encoding="utf-8")) + sample_definition = next( + value for value in manifest["samples"] if value["path"] == sample + ) + sample_id = sample_definition["id"] + sample_dir = self.results / sample_id + sample_dir.mkdir() + (sample_dir / "diagnostics.log").write_text(diagnostic_text, encoding="utf-8") + (sample_dir / "sample-result.json").write_text( + json.dumps( + { + "schema_version": manifest["schema_version"], + "sample": sample_definition, + "outcome": outcome, + "completed_stage": completed_stage, + "duration_seconds": 12.5, + "diagnostic_reference": "diagnostics.log", + "artifact_reference": f"validation-pilot-{sample_id}", + "completed_at": "2026-08-10T19:22:33Z", + "run": { + "repository": "example/repo", + "workflow": "validation pilot", + "run_id": "42", + "run_attempt": "1", + "sha": "abcdef0", + "ref": "refs/heads/main", + "started_at": "2026-08-10T19:22:00Z", + }, + } + ), + encoding="utf-8", + ) + + def test_renders_all_outcomes_and_run_freshness(self) -> None: + self.write_result(SAMPLE_A, "passed") + self.write_result(SAMPLE_B, "sample failure") + completed = self.run_report() + self.assertEqual(completed.returncode, 0, completed.stderr) + body = self.output.read_text(encoding="utf-8") + self.assertIn("✅ Passed", body) + self.assertIn("❌ Sample failure", body) + self.assertIn("Workflow run", body) + self.assertIn("Sample failures (1)", body) + self.assertIn("Passed (1)", body) + self.assertIn("https://github.com/example/repo/tree/abcdef0", body) + self.assertEqual(body.count("`samples/"), 2) + + def test_renders_skip_reason_and_sanitized_truncated_excerpt(self) -> None: + self.expected.write_text( + json.dumps( + { + "schema_version": 1, + "samples": [ + {"id": "a", "path": SAMPLE_A, "language": "python", "shape": "quickstart"}, + {"id": "b", "path": SAMPLE_B, "language": "csharp", "shape": "quickstart"}, + {"id": "c", "path": "samples/rust/quickstart/c", "language": "rust", "shape": "quickstart"}, + {"id": "d", "path": "samples/typescript/quickstart/d", "language": "typescript", "shape": "quickstart"}, + ], + } + ), + encoding="utf-8", + ) + self.write_result(SAMPLE_A, "passed") + self.write_result(SAMPLE_B, "sample failure", "FAIL: password=secret " + ("x" * 300)) + self.write_result("samples/rust/quickstart/c", "skipped/not-completed", "skipped: unsupported language: rust\n") + self.write_result("samples/typescript/quickstart/d", "infrastructure/error", "runner error: validator unavailable\n") + completed = self.run_report() + self.assertEqual(completed.returncode, 0, completed.stderr) + body = self.output.read_text(encoding="utf-8") + self.assertIn("Sample failures (1)", body) + self.assertIn("Infrastructure/errors (1)", body) + self.assertIn("Skipped/not-completed (1)", body) + self.assertIn("unsupported language: rust", body) + self.assertIn("password=[redacted]", body) + self.assertNotIn("password=secret", body) + self.assertIn("…", body) + + def test_failure_excerpt_does_not_select_earlier_passing_verdict(self) -> None: + self.write_result(SAMPLE_A, "passed") + self.write_result( + SAMPLE_B, + "sample failure", + "\n".join( + [ + "PASS: L3 validation", + "verdict=pass", + "ModuleNotFoundError: No module named 'httpx'", + "FAIL: L4 command reported sample failure", + "verdict=fail", + ] + ), + ) + completed = self.run_report() + self.assertEqual(completed.returncode, 0, completed.stderr) + body = self.output.read_text(encoding="utf-8") + self.assertIn("FAIL: L4 command reported sample failure", body) + self.assertNotIn("`verdict=pass`", body) + + def test_missing_expected_artifact_publishes_partial_summary_and_fails(self) -> None: + self.write_result(SAMPLE_A) + completed = self.run_report() + self.assertEqual(completed.returncode, 1) + body = self.output.read_text(encoding="utf-8") + self.assertIn("expected result artifact is missing", body) + self.assertIn("⚠️ Infrastructure/error", body) + + def test_schema_one_stage_names_are_normalized_for_historical_artifacts(self) -> None: + manifest = json.loads(self.expected.read_text(encoding="utf-8")) + manifest["schema_version"] = 1 + self.expected.write_text(json.dumps(manifest), encoding="utf-8") + self.write_result(SAMPLE_A, completed_stage="L3 validation") + self.write_result(SAMPLE_B, completed_stage="L4 validation") + completed = self.run_report() + self.assertEqual(completed.returncode, 0, completed.stderr) + body = self.output.read_text(encoding="utf-8") + self.assertIn("build readiness validation", body) + self.assertIn("live-service validation", body) + self.assertNotIn("L3 validation", body) + self.assertNotIn("L4 validation", body) + + def test_malformed_artifact_publishes_error_row_and_fails(self) -> None: + bad = self.results / "bad" + bad.mkdir() + (bad / "sample-result.json").write_text("{", encoding="utf-8") + completed = self.run_report() + self.assertEqual(completed.returncode, 1) + body = self.output.read_text(encoding="utf-8") + self.assertIn("invalid artifact: sample-result.json", body) + self.assertIn("⚠️ Infrastructure/error", body) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/test/test_validation_pilot.py b/.github/scripts/test/test_validation_pilot.py new file mode 100644 index 000000000..773a02579 --- /dev/null +++ b/.github/scripts/test/test_validation_pilot.py @@ -0,0 +1,451 @@ +#!/usr/bin/env python3 +"""Focused contract tests for the representative validation pilot producer.""" + +from __future__ import annotations + +import importlib.util +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[2] +RUNNER = ROOT / "scripts" / "run-validation-pilot.py" +DISCOVERY = ROOT / "scripts" / "discover-validation-samples.py" +COMPLETENESS = ROOT / "scripts" / "validate-validation-pilot-results.py" +WORKFLOW = ROOT / "workflows" / "validation-pilot.yml" +SELFTEST_WORKFLOW = ROOT / "workflows" / "scripts-selftest.yml" + +DISCOVERY_SPEC = importlib.util.spec_from_file_location("validation_discovery", DISCOVERY) +assert DISCOVERY_SPEC and DISCOVERY_SPEC.loader +validation_discovery = importlib.util.module_from_spec(DISCOVERY_SPEC) +DISCOVERY_SPEC.loader.exec_module(validation_discovery) + + +class ValidationPilotTests(unittest.TestCase): + def test_workflow_calls_report_after_completeness(self) -> None: + workflow = WORKFLOW.read_text(encoding="utf-8") + self.assertIn(" report:", workflow) + self.assertIn("needs: completeness", workflow) + self.assertIn("if: ${{ always() && !cancelled() }}", workflow) + self.assertIn("uses: ./.github/workflows/validation-report.yml", workflow) + self.assertIn( + "results-artifact: validation-pilot-run-${{ github.run_id }}-${{ github.run_attempt }}", + workflow, + ) + + def test_discovery_covers_full_inventory_and_explicitly_skips_rust(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + metadata = [ + ("csharp", "zeta", "name: zeta\n"), + ("java", "alpha", "name: alpha\n"), + ( + "python", + "beta", + "name: beta\nlive_service_validation:\n command: \"true\"\n", + ), + ("typescript", "gamma", "name: gamma\n"), + ("javascript", "delta", "name: delta\n"), + ("rust", "epsilon", "name: epsilon\n"), + ] + for language, name, contents in metadata: + sample_metadata = root / "samples" / language / name / "sample.yaml" + sample_metadata.parent.mkdir(parents=True) + sample_metadata.write_text(contents, encoding="utf-8") + manifest = root / "manifest.json" + matrix = root / "matrix.json" + build_readiness_matrix = root / "build-readiness-matrix.json" + live_service_matrix = root / "live-service-matrix.json" + completed = subprocess.run( + [ + sys.executable, + str(DISCOVERY), + "--root", + str(root), + "--manifest", + str(manifest), + "--matrix", + str(matrix), + "--build-readiness-matrix", + str(build_readiness_matrix), + "--live-service-matrix", + str(live_service_matrix), + ], + capture_output=True, + text=True, + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + payload = json.loads(manifest.read_text(encoding="utf-8")) + self.assertEqual(payload["schema_version"], 2) + self.assertEqual( + [sample["path"] for sample in payload["samples"]], + [ + "samples/csharp/zeta", + "samples/java/alpha", + "samples/javascript/delta", + "samples/python/beta", + "samples/rust/epsilon", + "samples/typescript/gamma", + ], + ) + self.assertTrue( + all( + set(sample) == {"id", "path", "language", "shape"} + for sample in payload["samples"] + ) + ) + self.assertEqual( + {value["validator_language"] for value in payload["validation"].values() if value["validator_language"]}, + {"csharp", "java", "python", "typescript"}, + ) + self.assertTrue(all(sample["shape"] == "full-fleet" for sample in payload["samples"])) + declared_live_service_paths = { + sample["path"] + for sample in payload["samples"] + if payload["validation"][sample["id"]][ + "live_service_validation_declared" + ] + } + self.assertEqual( + declared_live_service_paths, + {"samples/python/beta"}, + ) + self.assertEqual( + payload["validation"]["javascript-delta"]["validator_language"], + "typescript", + ) + self.assertEqual( + payload["validation"]["rust-epsilon"], + { + "eligible": False, + "live_service_validation_declared": False, + "skip_reason": "language 'rust' is not supported by build readiness", + "validator_language": "", + }, + ) + self.assertEqual(json.loads(matrix.read_text(encoding="utf-8"))["include"], [ + {**sample, **payload["validation"][sample["id"]]} for sample in payload["samples"] + ]) + self.assertTrue( + all( + not sample["live_service_validation_declared"] + for sample in json.loads( + build_readiness_matrix.read_text() + )["include"] + ) + ) + self.assertEqual( + { + sample["path"] + for sample in json.loads(live_service_matrix.read_text())[ + "include" + ] + }, + declared_live_service_paths, + ) + + def test_workflow_isolates_declared_live_service_warm_project_jobs(self) -> None: + workflow = WORKFLOW.read_text(encoding="utf-8") + self.assertEqual(workflow.count("environment: L4-validation"), 1) + self.assertIn( + "matrix: ${{ fromJSON(needs.discover.outputs.build_readiness_matrix) }}", + workflow, + ) + self.assertIn( + "matrix: ${{ fromJSON(needs.discover.outputs.live_service_matrix) }}", + workflow, + ) + self.assertIn( + "AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}", + workflow, + ) + self.assertIn( + "MODEL_DEPLOYMENT: ${{ vars.MODEL_DEPLOYMENT }}", + workflow, + ) + self.assertIn('SKIP_PROVISION: "true"', workflow) + self.assertIn('python -m pip install -r "${{ matrix.path }}/requirements.txt"', workflow) + + def test_discovery_jobs_install_pinned_dependencies(self) -> None: + for workflow_path in (WORKFLOW, SELFTEST_WORKFLOW): + workflow = workflow_path.read_text(encoding="utf-8") + self.assertIn("uses: actions/setup-python@v5", workflow) + self.assertIn("python-version: '3.12'", workflow) + self.assertIn( + "python -m pip install -r .github/scripts/requirements.txt", + workflow, + ) + + def test_discovery_rejects_legacy_l4_metadata_with_migration_message(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + sample = root / "samples" / "python" / "legacy" + sample.mkdir(parents=True) + (sample / "sample.yaml").write_text( + "name: legacy\nl4:\n command: \"true\"\n", + encoding="utf-8", + ) + completed = subprocess.run( + [ + sys.executable, + str(DISCOVERY), + "--root", + str(root), + "--manifest", + str(root / "manifest.json"), + "--matrix", + str(root / "matrix.json"), + ], + capture_output=True, + text=True, + ) + self.assertNotEqual(completed.returncode, 0) + self.assertIn( + "rename it to 'live_service_validation'", completed.stderr + ) + + def test_discovery_recognizes_indented_live_service_metadata(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + sample = root / "samples" / "python" / "indented" + sample.mkdir(parents=True) + (sample / "sample.yaml").write_text( + " name: indented\n" + " live_service_validation:\n" + " command: \"true\"\n", + encoding="utf-8", + ) + completed = subprocess.run( + [ + sys.executable, + str(DISCOVERY), + "--root", + str(root), + "--manifest", + str(root / "manifest.json"), + "--matrix", + str(root / "matrix.json"), + ], + capture_output=True, + text=True, + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + manifest = json.loads( + (root / "manifest.json").read_text(encoding="utf-8") + ) + self.assertTrue( + manifest["validation"]["python-indented"][ + "live_service_validation_declared" + ] + ) + + def test_discovery_skips_malformed_yaml_and_continues(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + broken = root / "samples" / "python" / "broken" / "sample.yaml" + broken.parent.mkdir(parents=True) + broken.write_text("live_service_validation: [\n", encoding="utf-8") + valid = root / "samples" / "python" / "valid" / "sample.yaml" + valid.parent.mkdir(parents=True) + valid.write_text("name: valid\n", encoding="utf-8") + + payload = validation_discovery.discover(root) + + broken_validation = payload["validation"]["python-broken"] + self.assertFalse(broken_validation["eligible"]) + self.assertFalse( + broken_validation["live_service_validation_declared"] + ) + self.assertIn( + "samples/python/broken/sample.yaml: invalid YAML", + broken_validation["skip_reason"], + ) + self.assertTrue(payload["validation"]["python-valid"]["eligible"]) + + def test_discovery_marks_unreadable_yaml_with_metadata_path(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + metadata = root / "samples" / "python" / "unreadable" / "sample.yaml" + metadata.parent.mkdir(parents=True) + metadata.write_text("name: unreadable\n", encoding="utf-8") + with mock.patch.object( + Path, + "read_text", + side_effect=PermissionError("permission denied"), + ): + payload = validation_discovery.discover(root) + + validation = payload["validation"]["python-unreadable"] + self.assertFalse(validation["eligible"]) + self.assertRegex( + validation["skip_reason"], + r"samples/python/unreadable/sample\.yaml: could not read", + ) + + def test_discovery_rejects_duplicate_derived_sample_ids(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + first = root / "samples" / "python" / "a-b" / "sample.yaml" + second = root / "samples" / "python" / "a" / "b" / "sample.yaml" + first.parent.mkdir(parents=True) + second.parent.mkdir(parents=True) + first.write_text("name: first\n", encoding="utf-8") + second.write_text("live_service_validation: [\n", encoding="utf-8") + + with self.assertRaisesRegex( + validation_discovery.DiscoveryError, + "duplicate derived sample ID 'python-a-b'", + ): + validation_discovery.discover(root) + + def test_sample_failure_is_a_complete_valid_result(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + validator = root / "validator.sh" + validator.write_text("import sys\nsys.exit(1)\n", encoding="utf-8") + output = root / "sample-result.json" + diagnostic = root / "diagnostics.log" + completed = subprocess.run( + [ + sys.executable, + str(RUNNER), + "--sample-id", + "fixture", + "--language", + "python", + "--shape", + "fixture", + "--sample-path", + "samples/python/fixture", + "--validator", + str(validator), + "--bash", + sys.executable, + "--output", + str(output), + "--diagnostic", + str(diagnostic), + ], + capture_output=True, + text=True, + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + result = json.loads(output.read_text(encoding="utf-8")) + self.assertEqual(result["outcome"], "sample failure") + self.assertEqual(result["completed_stage"], "build readiness validation") + self.assertTrue(diagnostic.is_file()) + + def test_completeness_rejects_missing_matrix_member(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + manifest = root / "manifest.json" + manifest.write_text( + json.dumps( + { + "schema_version": 2, + "samples": [ + {"id": "one", "path": "samples/python/one", "language": "python", "shape": "fixture"}, + {"id": "two", "path": "samples/java/two", "language": "java", "shape": "fixture"}, + ], + } + ), + encoding="utf-8", + ) + artifacts = root / "artifacts" / "validation-pilot-one" + artifacts.mkdir(parents=True) + (artifacts / "diagnostics.log").write_text("diagnostic\n", encoding="utf-8") + (artifacts / "sample-result.json").write_text( + json.dumps( + { + "schema_version": 2, + "sample": {"id": "one", "path": "samples/python/one", "language": "python", "shape": "fixture"}, + "outcome": "passed", + "completed_stage": "build readiness validation", + "duration_seconds": 1, + "diagnostic_reference": "diagnostics.log", + "artifact_reference": "sample-result.json", + "completed_at": "2026-08-10T00:00:00Z", + "run": {"run_id": "1"}, + } + ), + encoding="utf-8", + ) + completed = subprocess.run( + [ + sys.executable, + str(COMPLETENESS), + "--manifest", + str(manifest), + "--artifacts", + str(root / "artifacts"), + ], + capture_output=True, + text=True, + ) + self.assertNotEqual(completed.returncode, 0) + self.assertIn("missing result artifacts: two", completed.stderr) + + def test_completeness_accepts_historical_schema_one_artifacts(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + sample = { + "id": "one", + "path": "samples/python/one", + "language": "python", + "shape": "fixture", + } + manifest = root / "manifest.json" + manifest.write_text( + json.dumps({"schema_version": 1, "samples": [sample]}), + encoding="utf-8", + ) + artifact = root / "artifacts" / "validation-pilot-one" + artifact.mkdir(parents=True) + (artifact / "diagnostics.log").write_text( + "diagnostic\n", encoding="utf-8" + ) + (artifact / "sample-result.json").write_text( + json.dumps( + { + "schema_version": 1, + "sample": sample, + "outcome": "passed", + "completed_stage": "L3 validation", + "duration_seconds": 1, + "diagnostic_reference": "diagnostics.log", + "artifact_reference": "sample-result.json", + "completed_at": "2026-08-10T00:00:00Z", + "run": {"run_id": "1"}, + } + ), + encoding="utf-8", + ) + completed = subprocess.run( + [ + sys.executable, + str(COMPLETENESS), + "--manifest", + str(manifest), + "--artifacts", + str(root / "artifacts"), + ], + capture_output=True, + text=True, + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + summary = json.loads( + (root / "artifacts" / "run-summary.json").read_text( + encoding="utf-8" + ) + ) + self.assertEqual(summary["schema_version"], 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/validate-validation-pilot-results.py b/.github/scripts/validate-validation-pilot-results.py new file mode 100644 index 000000000..4642710d8 --- /dev/null +++ b/.github/scripts/validate-validation-pilot-results.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Fail closed when a pilot run did not persist one complete result per attempt.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +REQUIRED_OUTCOMES = {"passed", "sample failure", "infrastructure/error", "skipped/not-completed"} +REQUIRED_FIELDS = { + "schema_version", "sample", "outcome", "completed_stage", "duration_seconds", + "diagnostic_reference", "artifact_reference", "completed_at", "run", +} +SUPPORTED_SCHEMA_VERSIONS = {1, 2} + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--manifest", type=Path, required=True) + parser.add_argument("--artifacts", type=Path, required=True) + args = parser.parse_args() + manifest = json.loads(args.manifest.read_text(encoding="utf-8")) + schema_version = manifest.get("schema_version") + if schema_version not in SUPPORTED_SCHEMA_VERSIONS: + print( + f"ERROR: unsupported manifest schema_version: {schema_version!r}", + file=sys.stderr, + ) + return 1 + expected = {sample["id"]: sample for sample in manifest["samples"]} + found = {} + errors = [] + for result_path in sorted(args.artifacts.glob("*/sample-result.json")): + try: + result = json.loads(result_path.read_text(encoding="utf-8")) + missing = REQUIRED_FIELDS - result.keys() + sample = result["sample"] + if ( + missing + or result["schema_version"] != schema_version + or result["outcome"] not in REQUIRED_OUTCOMES + ): + raise ValueError(f"invalid schema or outcome (missing={sorted(missing)})") + sample_id = sample["id"] + if sample_id not in expected or sample_id in found: + raise ValueError(f"unexpected or duplicate sample id: {sample_id}") + if sample != expected[sample_id] and any( + sample.get(key) != expected[sample_id].get(key) + for key in ("id", "path", "language", "shape") + ): + raise ValueError("sample identity does not match manifest") + diagnostic = result_path.parent / result["diagnostic_reference"] + if not diagnostic.is_file(): + raise ValueError(f"missing diagnostic: {diagnostic}") + found[sample_id] = result + except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: + errors.append(f"{result_path}: {exc}") + missing_ids = sorted(set(expected) - set(found)) + if missing_ids: + errors.append("missing result artifacts: " + ", ".join(missing_ids)) + if errors: + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + output = args.artifacts / "run-summary.json" + output.write_text( + json.dumps( + {"schema_version": schema_version, "results": found}, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + print(f"validated {len(found)} complete sample results") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/validation-pilot.README.md b/.github/validation-pilot.README.md new file mode 100644 index 000000000..cd06570eb --- /dev/null +++ b/.github/validation-pilot.README.md @@ -0,0 +1,33 @@ +# Daily public validation cadence + +`validation-pilot.yml` runs daily at 07:00 UTC and can also be dispatched +manually. It discovers every `samples/**/sample.yaml` at the checked-out commit, +generates a deterministic manifest and matrix, and runs with `fail-fast: false`. +The first full-fleet run should be manually reviewed before the first scheduled +occurrence. + +All supported samples run build readiness. Samples declaring live-service validation use a separate credentialed +matrix leg to avoid duplicate validation and unnecessary environment deployment +records, but that leg still runs build readiness first and proceeds to live-service +validation only when readiness passes. Declaring live-service validation never opts +a sample out of build readiness. JavaScript uses the existing +TypeScript/node validator mapping. Rust samples remain in the manifest and emit +`skipped/not-completed` with an explicit unsupported-language reason. + +Each matrix leg persists `sample-result.json` and `diagnostics.log` in a +versioned artifact. Declared `sample.yaml` live-service commands run through the +existing `L4-validation` OIDC environment and warm-project seam. That environment +name is a legacy external GitHub/Entra identifier and is not the validation mode +name. P4.1 does not provision +resources and does not set a cold-provisioning default. + +The result schema remains owned by the producer and includes +sample identity, one of `passed`, `sample failure`, `infrastructure/error`, or +`skipped/not-completed`, the completed stage, duration, references to the +diagnostic and result artifacts, completion time, and GitHub run metadata. + +The completeness job fails the run if any discovered sample is missing, +duplicated, malformed, or missing its diagnostic. Individual sample failures +remain valid result records and do not prevent later matrix legs from running. +The generated manifest is included in the normalized run artifact so the +same-run report consumes exactly the inventory that was executed. diff --git a/.github/workflows/scripts-selftest.yml b/.github/workflows/scripts-selftest.yml new file mode 100644 index 000000000..02a328752 --- /dev/null +++ b/.github/workflows/scripts-selftest.yml @@ -0,0 +1,189 @@ +name: scripts self-test + +# Regression CI for the two shell scripts that ARE the public-first validation pipeline: +# .github/scripts/validate-sample.sh — per-language pass(0)/fail(1)/error(2) classifier +# .github/scripts/detect-changed-samples.sh — changed-sample resolver + GH Actions job outputs +# It runs the harnesses under .github/scripts/test/ whenever the scripts (or their tests) change, +# so a regression in the validator/detector/harness is caught BEFORE merge. +# +# This is SEPARATE, NON-REQUIRED dev-tooling CI — it must never touch, depend on, or gate the +# production `validate.yml` lanes (`validate` / `validate / trusted`). It is credential-free by +# construction: permissions is contents:read only, no secrets, no OIDC, no privileged runners. +# These are pure script tests. Do NOT make any job here a required check and do NOT edit rulesets. +# +# Trigger scope: validation scripts, their tests, or the two validation workflow definitions. +# A normal sample-authoring PR (samples/**, docs, etc.) never triggers it, so there is zero added +# friction for contributors. +# No `push:` trigger: PR-time is the gate that matters; `workflow_dispatch` covers manual/backfill. +on: + workflow_dispatch: + pull_request: + paths: + - '.github/scripts/**' + - '.github/workflows/validate.yml' + - '.github/workflows/scripts-selftest.yml' + - '.github/workflows/validation-report.yml' + - '.github/workflows/validation-pilot.yml' + - '.github/scripts/discover-validation-samples.py' + +permissions: + contents: read + +jobs: + # --- Required-gate structure: hermetic Bash assertions, no credentials ------------------------- + workflow-structure-harness: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: validate.yml trusted-gate structure exit gate + run: bash .github/scripts/test/run-workflow-structure-tests.sh + + # --- Detector harness: hermetic (git + coreutils only), runs fully GREEN anywhere -------------- + detect-harness: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: detect-changed-samples exit gate (resolve/dedupe/walk-up/docs-only/fail-loud) + run: bash .github/scripts/test/run-detect-tests.sh + + # --- Validator harness: needs all 5 language toolchains on PATH so run-tests.sh reaches GREEN --- + # (exit 0). A missing toolchain would make the harness exit 3 = PARTIAL, which is non-zero and + # therefore fails this job — that is the desired behavior: PARTIAL must never masquerade as pass. + # `mvn` (Java's build) is preinstalled on ubuntu-latest; the five setup-* steps + yq provide the + # rest. The toolchain/yq setup below is reused verbatim from the retired validate-sample-selftest. + validate-harness: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Set up Java (Temurin) + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.22' + + - name: Install yq + run: | + sudo wget -qO /usr/local/bin/yq \ + https://github.com/mikefarah/yq/releases/download/v4.44.3/yq_linux_amd64 + sudo chmod +x /usr/local/bin/yq + yq --version + + - name: Toolchain versions + run: | + dotnet --version + python --version + node --version + npm --version + java -version + go version + + - name: Phase-1 exit gate — all 5 languages, pass(0)/fail(1)/error(2) + run: bash .github/scripts/test/run-tests.sh + + # --- Reporting consumer contract tests: fixtures only, no producer coupling ---------------------- + report-harness: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run normalized-artifact reporting tests + run: python .github/scripts/test/test-render-validation-report.py + + validation-pilot-contract: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + cache-dependency-path: .github/scripts/requirements.txt + - name: Install validation script dependencies + run: python -m pip install -r .github/scripts/requirements.txt + - name: Producer contract tests + run: python -m unittest discover -s .github/scripts/test -p 'test_validation_pilot.py' + + # --- Plumbing isolation proof: detector outputs cross a REAL job boundary via needs.*.outputs --- + # Proves the $GITHUB_OUTPUT -> steps.*.outputs -> job.outputs -> downstream needs.*.outputs chain + # in ISOLATION from validate.yml's trust/gate logic (which only exercises it on trusted gated PRs). + # Non-empty case only: the docs-only / empty-result emission contract is already pinned hermetically + # by run-detect-tests.sh case (h), so re-proving it across a needs boundary adds no unique signal. + detect: + runs-on: ubuntu-latest + outputs: + has_changes: ${{ steps.detect.outputs.has_changes }} + count: ${{ steps.detect.outputs.count }} + samples: ${{ steps.detect.outputs.samples }} + steps: + - uses: actions/checkout@v4 + - name: Build a deterministic temp scenario and run the detector + id: detect + run: | + set -euo pipefail + SCRIPT="$GITHUB_WORKSPACE/.github/scripts/detect-changed-samples.sh" + REPO="$(mktemp -d)" + cd "$REPO" + git init -q + git config user.email t@t.test + git config user.name test + mkdir -p samples/python/quickstart/foo samples/csharp/quickstart/bar + printf 'name: foo\n' > samples/python/quickstart/foo/sample.yaml + printf 'print("v1")\n' > samples/python/quickstart/foo/main.py + printf 'name: bar\n' > samples/csharp/quickstart/bar/sample.yaml + printf 'x\n' > samples/csharp/quickstart/bar/Program.cs + git add -A >/dev/null + git commit -qm base + BASE="$(git rev-parse HEAD)" + printf 'print("v2")\n' > samples/python/quickstart/foo/main.py + printf 'x2\n' > samples/csharp/quickstart/bar/Program.cs + git commit -qam change + # Runs with the REAL $GITHUB_OUTPUT -> emits has_changes/count/samples as STEP outputs. + bash "$SCRIPT" --base-ref "$BASE" + + consume: + needs: detect + runs-on: ubuntu-latest + steps: + - name: Read needs.detect.outputs.* and assert + env: + HAS_CHANGES: ${{ needs.detect.outputs.has_changes }} + COUNT: ${{ needs.detect.outputs.count }} + SAMPLES: ${{ needs.detect.outputs.samples }} + run: | + set -euo pipefail + echo "consumed via needs.detect.outputs.*:" + echo " has_changes=$HAS_CHANGES" + echo " count=$COUNT" + echo " samples=$SAMPLES" + fail=0 + [ "$HAS_CHANGES" = "true" ] || { echo "FAIL: has_changes expected true"; fail=1; } + [ "$COUNT" = "2" ] || { echo "FAIL: count expected 2"; fail=1; } + expected='["samples/csharp/quickstart/bar","samples/python/quickstart/foo"]' + [ "$SAMPLES" = "$expected" ] || { echo "FAIL: samples expected $expected"; fail=1; } + if [ "$fail" -ne 0 ]; then + echo "needs.*.outputs isolation proof: RED" + exit 1 + fi + echo "needs.*.outputs isolation proof: GREEN — downstream job consumed the detected set." diff --git a/.github/workflows/validation-pilot.yml b/.github/workflows/validation-pilot.yml new file mode 100644 index 000000000..151159b28 --- /dev/null +++ b/.github/workflows/validation-pilot.yml @@ -0,0 +1,220 @@ +name: Daily public validation cadence + +on: + schedule: + - cron: '0 7 * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: validation-pilot-main + cancel-in-progress: true + +jobs: + discover: + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + build_readiness_matrix: ${{ steps.discovery.outputs.build_readiness_matrix }} + live_service_matrix: ${{ steps.discovery.outputs.live_service_matrix }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + cache-dependency-path: .github/scripts/requirements.txt + - name: Install validation script dependencies + run: python -m pip install -r .github/scripts/requirements.txt + - name: Discover full sample inventory + id: discovery + run: | + set -euo pipefail + python .github/scripts/discover-validation-samples.py \ + --manifest "$RUNNER_TEMP/validation-manifest.json" \ + --matrix "$RUNNER_TEMP/validation-matrix.json" \ + --build-readiness-matrix "$RUNNER_TEMP/validation-build-readiness-matrix.json" \ + --live-service-matrix "$RUNNER_TEMP/validation-live-service-matrix.json" > "$RUNNER_TEMP/discovery.json" + printf 'build_readiness_matrix=%s\n' "$(cat "$RUNNER_TEMP/validation-build-readiness-matrix.json")" >> "$GITHUB_OUTPUT" + printf 'live_service_matrix=%s\n' "$(cat "$RUNNER_TEMP/validation-live-service-matrix.json")" >> "$GITHUB_OUTPUT" + cat "$RUNNER_TEMP/discovery.json" + - name: Persist discovered manifest + uses: actions/upload-artifact@v4 + with: + name: validation-pilot-manifest + path: ${{ runner.temp }}/validation-manifest.json + if-no-files-found: error + retention-days: 90 + + build-readiness: + needs: discover + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + strategy: + fail-fast: false + max-parallel: 32 + matrix: ${{ fromJSON(needs.discover.outputs.build_readiness_matrix) }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + if: matrix.validator_language == 'csharp' + with: + dotnet-version: 8.0.x + - uses: actions/setup-python@v5 + if: matrix.validator_language == 'python' + with: + python-version: '3.12' + - uses: actions/setup-node@v4 + if: matrix.validator_language == 'typescript' + with: + node-version: '20' + - uses: actions/setup-java@v4 + if: matrix.validator_language == 'java' + with: + distribution: temurin + java-version: '17' + - name: Install yq + if: matrix.eligible + run: | + sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/download/v4.44.3/yq_linux_amd64 + sudo chmod +x /usr/local/bin/yq + - name: Run normalized build-readiness validation + if: always() + run: | + set -uo pipefail + mkdir -p "$RUNNER_TEMP/pilot-result" + args=( + --sample-id "${{ matrix.id }}" + --language "${{ matrix.language }}" + --validator-language "${{ matrix.validator_language }}" + --shape "${{ matrix.shape }}" + --sample-path "${{ matrix.path }}" + --output "$RUNNER_TEMP/pilot-result/sample-result.json" + --diagnostic "$RUNNER_TEMP/pilot-result/diagnostics.log" + ) + if [ "${{ matrix.eligible }}" != "true" ]; then + args+=(--skip-reason "${{ matrix.skip_reason }}") + fi + python .github/scripts/run-validation-pilot.py "${args[@]}" + - name: Persist sample result and diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: validation-pilot-${{ matrix.id }} + path: ${{ runner.temp }}/pilot-result + if-no-files-found: error + retention-days: 30 + + live-service: + needs: discover + runs-on: ubuntu-latest + timeout-minutes: 30 + # Legacy external identifier: renaming it also requires an Entra OIDC subject migration. + environment: L4-validation + permissions: + contents: read + id-token: write + env: + # P4.1 never provisions. P4.2 owns any future cold-provisioning caller. + SKIP_PROVISION: "true" + AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }} + MODEL_DEPLOYMENT: ${{ vars.MODEL_DEPLOYMENT }} + strategy: + fail-fast: false + max-parallel: 4 + matrix: ${{ fromJSON(needs.discover.outputs.live_service_matrix) }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + if: matrix.validator_language == 'csharp' + with: + dotnet-version: 8.0.x + - uses: actions/setup-python@v5 + if: matrix.validator_language == 'python' + with: + python-version: '3.12' + - name: Install yq + run: | + sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/download/v4.44.3/yq_linux_amd64 + sudo chmod +x /usr/local/bin/yq + - name: Install declared live-service Python runtime dependencies + if: matrix.validator_language == 'python' + run: python -m pip install -r "${{ matrix.path }}/requirements.txt" + - name: Azure login + uses: azure/login@v2 + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + - name: Run normalized live-service validation + if: always() + run: | + set -uo pipefail + mkdir -p "$RUNNER_TEMP/pilot-result" + python .github/scripts/run-validation-pilot.py \ + --sample-id "${{ matrix.id }}" \ + --language "${{ matrix.language }}" \ + --validator-language "${{ matrix.validator_language }}" \ + --shape "${{ matrix.shape }}" \ + --sample-path "${{ matrix.path }}" \ + --output "$RUNNER_TEMP/pilot-result/sample-result.json" \ + --diagnostic "$RUNNER_TEMP/pilot-result/diagnostics.log" \ + --run-live-service + - name: Persist sample result and diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: validation-pilot-${{ matrix.id }} + path: ${{ runner.temp }}/pilot-result + if-no-files-found: error + retention-days: 30 + + completeness: + if: always() + needs: [discover, build-readiness, live-service] + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + pattern: validation-pilot-* + path: ${{ runner.temp }}/pilot-artifacts + - name: Validate complete normalized result set + run: | + set -uo pipefail + manifest="$RUNNER_TEMP/pilot-artifacts/validation-pilot-manifest/validation-manifest.json" + if python .github/scripts/validate-validation-pilot-results.py \ + --manifest "$manifest" \ + --artifacts "$RUNNER_TEMP/pilot-artifacts"; then + completeness_rc=0 + else + completeness_rc=$? + fi + cp "$manifest" "$RUNNER_TEMP/pilot-artifacts/manifest.json" + exit "$completeness_rc" + - name: Persist run metadata and normalized summary + if: always() + run: | + python -c 'import json,os; from pathlib import Path; p=Path(os.environ["RUNNER_TEMP"])/"pilot-artifacts"/"run-metadata.json"; p.write_text(json.dumps({"schema_version":2,"repository":os.environ["GITHUB_REPOSITORY"],"workflow":os.environ["GITHUB_WORKFLOW"],"run_id":os.environ["GITHUB_RUN_ID"],"run_attempt":os.environ["GITHUB_RUN_ATTEMPT"],"sha":os.environ["GITHUB_SHA"],"ref":os.environ["GITHUB_REF"],"completed_at":__import__("datetime").datetime.now(__import__("datetime").timezone.utc).isoformat().replace("+00:00","Z")},indent=2)+"\n")' + continue-on-error: true + - name: Upload normalized run + if: always() + uses: actions/upload-artifact@v4 + with: + name: validation-pilot-run-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/pilot-artifacts + if-no-files-found: error + retention-days: 90 + + report: + needs: completeness + if: ${{ always() && !cancelled() }} + uses: ./.github/workflows/validation-report.yml + with: + results-artifact: validation-pilot-run-${{ github.run_id }}-${{ github.run_attempt }} + manifest-path: manifest.json diff --git a/.github/workflows/validation-report.yml b/.github/workflows/validation-report.yml new file mode 100644 index 000000000..b0a6a2cca --- /dev/null +++ b/.github/workflows/validation-report.yml @@ -0,0 +1,69 @@ +name: validation report + +# Reusable consumer job for validation-pilot schemas 1 and 2. The producer owns the schema and +# artifact completeness; this workflow only consumes normalized results and diagnostics. +on: + workflow_call: + inputs: + results-artifact: + required: true + type: string + manifest-path: + required: false + default: manifest.json + type: string + +permissions: + contents: read + +jobs: + report: + runs-on: ubuntu-latest + if: ${{ !cancelled() }} + steps: + - uses: actions/checkout@v4 + - name: Download normalized result artifacts + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.results-artifact }} + path: ${{ runner.temp }}/validation-results + + - name: Validate completeness contract + id: render + if: always() + run: | + set -uo pipefail + if python .github/scripts/validate-validation-pilot-results.py \ + --manifest "$RUNNER_TEMP/validation-results/${{ inputs.manifest-path }}" \ + --artifacts "$RUNNER_TEMP/validation-results"; then + completeness_rc=0 + else + completeness_rc=$? + fi + if python .github/scripts/render-validation-report.py \ + --results-dir "$RUNNER_TEMP/validation-results" \ + --expected-samples "$RUNNER_TEMP/validation-results/${{ inputs.manifest-path }}" \ + --output "$RUNNER_TEMP/validation-report.md" \ + --run-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"; then + render_rc=0 + else + render_rc=$? + fi + if [ -f "$RUNNER_TEMP/validation-report.md" ]; then + cat "$RUNNER_TEMP/validation-report.md" >> "$GITHUB_STEP_SUMMARY" + fi + echo "completeness_rc=$completeness_rc" >> "$GITHUB_OUTPUT" + echo "render_rc=$render_rc" >> "$GITHUB_OUTPUT" + exit 0 + + - name: Fail incomplete reporting handoff + if: always() + env: + COMPLETENESS_RC: ${{ steps.render.outputs.completeness_rc }} + RENDER_RC: ${{ steps.render.outputs.render_rc }} + run: | + set -euo pipefail + if [ "$COMPLETENESS_RC" != "0" ] || [ "$RENDER_RC" != "0" ]; then + echo "::error::Validation reporting handoff was incomplete or malformed." + exit 1 + fi diff --git a/samples/csharp/foundry-local/Directory.Packages.props b/samples/csharp/foundry-local/Directory.Packages.props index d132b7211..d593db5a4 100644 --- a/samples/csharp/foundry-local/Directory.Packages.props +++ b/samples/csharp/foundry-local/Directory.Packages.props @@ -6,6 +6,8 @@ + + diff --git a/samples/csharp/foundry-local/audio-transcription-example/AudioTranscriptionExample.csproj b/samples/csharp/foundry-local/audio-transcription-example/AudioTranscriptionExample.csproj new file mode 100644 index 000000000..a37b4e527 --- /dev/null +++ b/samples/csharp/foundry-local/audio-transcription-example/AudioTranscriptionExample.csproj @@ -0,0 +1,54 @@ + + + + Exe + enable + enable + + + + + net8.0-windows10.0.18362.0 + ARM64;x64 + None + false + + + + + net8.0 + + + + $(NETCoreSdkRuntimeIdentifier) + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + + + + + + diff --git a/samples/csharp/foundry-local/audio-transcription-example/AudioTranscriptionExample.sln b/samples/csharp/foundry-local/audio-transcription-example/AudioTranscriptionExample.sln new file mode 100644 index 000000000..5e91cc9c2 --- /dev/null +++ b/samples/csharp/foundry-local/audio-transcription-example/AudioTranscriptionExample.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AudioTranscriptionExample", "AudioTranscriptionExample.csproj", "{11616852-BB4F-4B60-9FAC-D94E2688BB30}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {11616852-BB4F-4B60-9FAC-D94E2688BB30}.Debug|Any CPU.ActiveCfg = Debug|ARM64 + {11616852-BB4F-4B60-9FAC-D94E2688BB30}.Debug|Any CPU.Build.0 = Debug|ARM64 + {11616852-BB4F-4B60-9FAC-D94E2688BB30}.Debug|x64.ActiveCfg = Debug|x64 + {11616852-BB4F-4B60-9FAC-D94E2688BB30}.Debug|x64.Build.0 = Debug|x64 + {11616852-BB4F-4B60-9FAC-D94E2688BB30}.Debug|x86.ActiveCfg = Debug|ARM64 + {11616852-BB4F-4B60-9FAC-D94E2688BB30}.Debug|x86.Build.0 = Debug|ARM64 + {11616852-BB4F-4B60-9FAC-D94E2688BB30}.Release|Any CPU.ActiveCfg = Release|ARM64 + {11616852-BB4F-4B60-9FAC-D94E2688BB30}.Release|Any CPU.Build.0 = Release|ARM64 + {11616852-BB4F-4B60-9FAC-D94E2688BB30}.Release|x64.ActiveCfg = Release|x64 + {11616852-BB4F-4B60-9FAC-D94E2688BB30}.Release|x64.Build.0 = Release|x64 + {11616852-BB4F-4B60-9FAC-D94E2688BB30}.Release|x86.ActiveCfg = Release|ARM64 + {11616852-BB4F-4B60-9FAC-D94E2688BB30}.Release|x86.Build.0 = Release|ARM64 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/samples/csharp/foundry-local/audio-transcription-example/Program.cs b/samples/csharp/foundry-local/audio-transcription-example/Program.cs new file mode 100644 index 000000000..10047421a --- /dev/null +++ b/samples/csharp/foundry-local/audio-transcription-example/Program.cs @@ -0,0 +1,87 @@ +// +// +using Microsoft.AI.Foundry.Local; +// + +// +var config = new Configuration +{ + AppName = "foundry_local_samples", + LogLevel = Microsoft.AI.Foundry.Local.LogLevel.Information +}; + + +// Initialize the singleton instance. +await FoundryLocalManager.CreateAsync(config, Utils.GetAppLogger()); +var mgr = FoundryLocalManager.Instance; + + +// Ensure that any Execution Provider (EP) downloads run and are completed. +// Download and register all execution providers. +var currentEp = ""; +await mgr.DownloadAndRegisterEpsAsync((epName, percent) => +{ + if (epName != currentEp) + { + if (currentEp != "") Console.WriteLine(); + currentEp = epName; + } + Console.Write($"\r {epName.PadRight(30)} {percent,6:F1}%"); +}); +if (currentEp != "") Console.WriteLine(); +// + + +// +// Get the model catalog +var catalog = await mgr.GetCatalogAsync(); + + +// Get a model using an alias and select the CPU model variant +var model = await catalog.GetModelAsync("whisper-tiny") ?? throw new System.Exception("Model not found"); +var modelVariant = model.Variants.First(v => v.Info.Runtime?.DeviceType == DeviceType.CPU); +model.SelectVariant(modelVariant); + + +// Download the model (the method skips download if already cached) +await model.DownloadAsync(progress => +{ + Console.Write($"\rDownloading model: {progress:F2}%"); + if (progress >= 100f) + { + Console.WriteLine(); + } +}); + + +// Load the model +Console.Write($"Loading model {model.Id}..."); +await model.LoadAsync(); +Console.WriteLine("done."); +// + + +// +// Get an audio client +var audioClient = await model.GetAudioClientAsync(); +audioClient.Settings.Language = "en"; + +// Get a transcription with streaming outputs +var audioFile = args.Length > 0 ? args[0] : Path.Combine(AppContext.BaseDirectory, "Recording.mp3"); +Console.WriteLine($"Transcribing audio with streaming output: {Path.GetFileName(audioFile)}"); +var response = audioClient.TranscribeAudioStreamingAsync(audioFile, CancellationToken.None); +await foreach (var chunk in response) +{ + Console.Write(chunk.Text); + Console.Out.Flush(); +} + +Console.WriteLine(); +// + + +// +// Tidy up - unload the model +await model.UnloadAsync(); +// +// \ No newline at end of file diff --git a/samples/csharp/foundry-local/audio-transcription-example/README.md b/samples/csharp/foundry-local/audio-transcription-example/README.md new file mode 100644 index 000000000..0ca916a50 --- /dev/null +++ b/samples/csharp/foundry-local/audio-transcription-example/README.md @@ -0,0 +1,18 @@ +# Audio Transcription (C#) + +Transcribe audio files using the Foundry Local C# SDK. + +> Part of the [Foundry Local samples for C#](../). + +## Prerequisites + +- [Foundry Local](https://learn.microsoft.com/azure/foundry-local/) installed +- .NET 9 SDK + +## Run + +```bash +dotnet run +``` + +See the [Foundry Local documentation](https://learn.microsoft.com/azure/foundry-local/) for more details. diff --git a/samples/csharp/foundry-local/audio-transcription-example/Recording.mp3 b/samples/csharp/foundry-local/audio-transcription-example/Recording.mp3 new file mode 100644 index 000000000..deb38418b Binary files /dev/null and b/samples/csharp/foundry-local/audio-transcription-example/Recording.mp3 differ diff --git a/samples/csharp/foundry-local/audio-transcription-example/sample.yaml b/samples/csharp/foundry-local/audio-transcription-example/sample.yaml new file mode 100644 index 000000000..c27e9d14a --- /dev/null +++ b/samples/csharp/foundry-local/audio-transcription-example/sample.yaml @@ -0,0 +1,4 @@ +name: Audio Transcription (C#) +description: Transcribe audio files using the Foundry Local C# SDK. + +build: "dotnet restore ./*.csproj --source https://api.nuget.org/v3/index.json && dotnet build ./*.csproj --no-restore --verbosity minimal" diff --git a/samples/csharp/foundry-local/embeddings/Embeddings.csproj b/samples/csharp/foundry-local/embeddings/Embeddings.csproj new file mode 100644 index 000000000..4c2154b41 --- /dev/null +++ b/samples/csharp/foundry-local/embeddings/Embeddings.csproj @@ -0,0 +1,47 @@ + + + + Exe + enable + enable + + + + + net8.0-windows10.0.18362.0 + ARM64;x64 + None + false + + + + + net8.0 + + + + $(NETCoreSdkRuntimeIdentifier) + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/csharp/foundry-local/embeddings/Program.cs b/samples/csharp/foundry-local/embeddings/Program.cs new file mode 100644 index 000000000..724ab531e --- /dev/null +++ b/samples/csharp/foundry-local/embeddings/Program.cs @@ -0,0 +1,74 @@ +// +// +using Microsoft.AI.Foundry.Local; +// + +// +var config = new Configuration +{ + AppName = "foundry_local_samples", + LogLevel = Microsoft.AI.Foundry.Local.LogLevel.Information +}; + +// Initialize the singleton instance. +await FoundryLocalManager.CreateAsync(config, Utils.GetAppLogger()); +var mgr = FoundryLocalManager.Instance; +// + +// +// Get the model catalog +var catalog = await mgr.GetCatalogAsync(); + +// Get an embedding model +var model = await catalog.GetModelAsync("qwen3-embedding-0.6b") ?? throw new Exception("Embedding model not found"); + +// Download the model (the method skips download if already cached) +await model.DownloadAsync(progress => +{ + Console.Write($"\rDownloading model: {progress:F2}%"); + if (progress >= 100f) + { + Console.WriteLine(); + } +}); + +// Load the model +Console.Write($"Loading model {model.Id}..."); +await model.LoadAsync(); +Console.WriteLine("done."); +// + +// +// Get an embedding client +var embeddingClient = await model.GetEmbeddingClientAsync(); + +// Generate a single embedding +Console.WriteLine("\n--- Single Embedding ---"); +var response = await embeddingClient.GenerateEmbeddingAsync("The quick brown fox jumps over the lazy dog"); +var embedding = response.Data[0].Embedding; +Console.WriteLine($"Dimensions: {embedding.Count}"); +Console.WriteLine($"First 5 values: [{string.Join(", ", embedding.Take(5).Select(v => v.ToString("F6")))}]"); +// + +// +// Generate embeddings for multiple inputs +Console.WriteLine("\n--- Batch Embeddings ---"); +var batchResponse = await embeddingClient.GenerateEmbeddingsAsync([ + "Machine learning is a subset of artificial intelligence", + "The capital of France is Paris", + "Rust is a systems programming language" +]); + +Console.WriteLine($"Number of embeddings: {batchResponse.Data.Count}"); +for (var i = 0; i < batchResponse.Data.Count; i++) +{ + Console.WriteLine($" [{i}] Dimensions: {batchResponse.Data[i].Embedding.Count}"); +} +// + +// +// Tidy up - unload the model +await model.UnloadAsync(); +Console.WriteLine("\nModel unloaded."); +// +// diff --git a/samples/csharp/foundry-local/embeddings/README.md b/samples/csharp/foundry-local/embeddings/README.md new file mode 100644 index 000000000..48b36960f --- /dev/null +++ b/samples/csharp/foundry-local/embeddings/README.md @@ -0,0 +1,18 @@ +# Embeddings (C#) + +Generate single and batch text embeddings using the Foundry Local C# SDK. + +> Part of the [Foundry Local samples for C#](../). + +## Prerequisites + +- [Foundry Local](https://learn.microsoft.com/azure/foundry-local/) installed +- .NET 9 SDK + +## Run + +```bash +dotnet run +``` + +See the [Foundry Local documentation](https://learn.microsoft.com/azure/foundry-local/) for more details. diff --git a/samples/csharp/foundry-local/embeddings/sample.yaml b/samples/csharp/foundry-local/embeddings/sample.yaml new file mode 100644 index 000000000..4dff437dd --- /dev/null +++ b/samples/csharp/foundry-local/embeddings/sample.yaml @@ -0,0 +1,4 @@ +name: Embeddings (C#) +description: Generate single and batch text embeddings using the Foundry Local C# SDK. + +build: "dotnet restore ./*.csproj --source https://api.nuget.org/v3/index.json && dotnet build ./*.csproj --no-restore --verbosity minimal" diff --git a/samples/csharp/foundry-local/foundry-local-web-server/FoundryLocalWebServer.csproj b/samples/csharp/foundry-local/foundry-local-web-server/FoundryLocalWebServer.csproj new file mode 100644 index 000000000..fc6cbf2a7 --- /dev/null +++ b/samples/csharp/foundry-local/foundry-local-web-server/FoundryLocalWebServer.csproj @@ -0,0 +1,51 @@ + + + + Exe + enable + enable + + + + + net8.0-windows10.0.18362.0 + ARM64;x64 + None + false + + + + + net8.0 + + + + $(NETCoreSdkRuntimeIdentifier) + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/csharp/foundry-local/foundry-local-web-server/FoundryLocalWebServer.sln b/samples/csharp/foundry-local/foundry-local-web-server/FoundryLocalWebServer.sln new file mode 100644 index 000000000..3000caa6f --- /dev/null +++ b/samples/csharp/foundry-local/foundry-local-web-server/FoundryLocalWebServer.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FoundryLocalWebServer", "FoundryLocalWebServer.csproj", "{2DEC84E5-8530-45AF-B26D-EC78A6A7D6E7}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2DEC84E5-8530-45AF-B26D-EC78A6A7D6E7}.Debug|Any CPU.ActiveCfg = Debug|ARM64 + {2DEC84E5-8530-45AF-B26D-EC78A6A7D6E7}.Debug|Any CPU.Build.0 = Debug|ARM64 + {2DEC84E5-8530-45AF-B26D-EC78A6A7D6E7}.Debug|x64.ActiveCfg = Debug|x64 + {2DEC84E5-8530-45AF-B26D-EC78A6A7D6E7}.Debug|x64.Build.0 = Debug|x64 + {2DEC84E5-8530-45AF-B26D-EC78A6A7D6E7}.Debug|x86.ActiveCfg = Debug|ARM64 + {2DEC84E5-8530-45AF-B26D-EC78A6A7D6E7}.Debug|x86.Build.0 = Debug|ARM64 + {2DEC84E5-8530-45AF-B26D-EC78A6A7D6E7}.Release|Any CPU.ActiveCfg = Release|ARM64 + {2DEC84E5-8530-45AF-B26D-EC78A6A7D6E7}.Release|Any CPU.Build.0 = Release|ARM64 + {2DEC84E5-8530-45AF-B26D-EC78A6A7D6E7}.Release|x64.ActiveCfg = Release|x64 + {2DEC84E5-8530-45AF-B26D-EC78A6A7D6E7}.Release|x64.Build.0 = Release|x64 + {2DEC84E5-8530-45AF-B26D-EC78A6A7D6E7}.Release|x86.ActiveCfg = Release|ARM64 + {2DEC84E5-8530-45AF-B26D-EC78A6A7D6E7}.Release|x86.Build.0 = Release|ARM64 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/samples/csharp/foundry-local/foundry-local-web-server/Program.cs b/samples/csharp/foundry-local/foundry-local-web-server/Program.cs new file mode 100644 index 000000000..eb88e4b39 --- /dev/null +++ b/samples/csharp/foundry-local/foundry-local-web-server/Program.cs @@ -0,0 +1,100 @@ +// +// +using Microsoft.AI.Foundry.Local; +using OpenAI; +using System.ClientModel; +// + +// +var config = new Configuration +{ + AppName = "foundry_local_samples", + LogLevel = Microsoft.AI.Foundry.Local.LogLevel.Information, + Web = new Configuration.WebService + { + Urls = "http://127.0.0.1:52495" + } +}; + + +// Initialize the singleton instance. +await FoundryLocalManager.CreateAsync(config, Utils.GetAppLogger()); +var mgr = FoundryLocalManager.Instance; + + +// Ensure that any Execution Provider (EP) downloads run and are completed. +// Download and register all execution providers. +var currentEp = ""; +await mgr.DownloadAndRegisterEpsAsync((epName, percent) => +{ + if (epName != currentEp) + { + if (currentEp != "") Console.WriteLine(); + currentEp = epName; + } + Console.Write($"\r {epName.PadRight(30)} {percent,6:F1}%"); +}); +if (currentEp != "") Console.WriteLine(); +// + + +// +// Get the model catalog +var catalog = await mgr.GetCatalogAsync(); + + +// Get a model using an alias +var model = await catalog.GetModelAsync("qwen2.5-0.5b") ?? throw new Exception("Model not found"); +// Download the model (the method skips download if already cached) +await model.DownloadAsync(progress => +{ + Console.Write($"\rDownloading model: {progress:F2}%"); + if (progress >= 100f) + { + Console.WriteLine(); + } +}); + + +// Load the model +Console.Write($"Loading model {model.Id}..."); +await model.LoadAsync(); +Console.WriteLine("done."); +// + + +// +// Start the web service +Console.Write($"Starting web service on {config.Web.Urls}..."); +await mgr.StartWebServiceAsync(); +Console.WriteLine("done."); + +// <<<<<< OPEN AI SDK USAGE >>>>>> +// Use the OpenAI SDK to call the local Foundry web service + +ApiKeyCredential key = new ApiKeyCredential("notneeded"); +OpenAIClient client = new OpenAIClient(key, new OpenAIClientOptions +{ + Endpoint = new Uri(config.Web.Urls + "/v1"), +}); + +var chatClient = client.GetChatClient(model.Id); +var completionUpdates = chatClient.CompleteChatStreaming("Why is the sky blue?"); + +Console.Write($"[ASSISTANT]: "); +foreach (var completionUpdate in completionUpdates) +{ + if (completionUpdate.ContentUpdate.Count > 0) + { + Console.Write(completionUpdate.ContentUpdate[0].Text); + } +} +Console.WriteLine(); +// <<<<<< END OPEN AI SDK USAGE >>>>>> + +// Tidy up +// Stop the web service and unload model +await mgr.StopWebServiceAsync(); +await model.UnloadAsync(); +// +// \ No newline at end of file diff --git a/samples/csharp/foundry-local/foundry-local-web-server/README.md b/samples/csharp/foundry-local/foundry-local-web-server/README.md new file mode 100644 index 000000000..3f78dcda7 --- /dev/null +++ b/samples/csharp/foundry-local/foundry-local-web-server/README.md @@ -0,0 +1,18 @@ +# Foundry Local Web Server (C#) + +Start a local OpenAI-compatible web server and call it from C#. + +> Part of the [Foundry Local samples for C#](../). + +## Prerequisites + +- [Foundry Local](https://learn.microsoft.com/azure/foundry-local/) installed +- .NET 9 SDK + +## Run + +```bash +dotnet run +``` + +See the [Foundry Local documentation](https://learn.microsoft.com/azure/foundry-local/) for more details. diff --git a/samples/csharp/foundry-local/foundry-local-web-server/sample.yaml b/samples/csharp/foundry-local/foundry-local-web-server/sample.yaml new file mode 100644 index 000000000..04130a542 --- /dev/null +++ b/samples/csharp/foundry-local/foundry-local-web-server/sample.yaml @@ -0,0 +1,4 @@ +name: Foundry Local Web Server (C#) +description: Start a local OpenAI-compatible web server and call it from C#. + +build: "dotnet restore ./*.csproj --source https://api.nuget.org/v3/index.json && dotnet build ./*.csproj --no-restore --verbosity minimal" diff --git a/samples/csharp/foundry-local/model-management-example/ModelManagementExample.csproj b/samples/csharp/foundry-local/model-management-example/ModelManagementExample.csproj new file mode 100644 index 000000000..4c2154b41 --- /dev/null +++ b/samples/csharp/foundry-local/model-management-example/ModelManagementExample.csproj @@ -0,0 +1,47 @@ + + + + Exe + enable + enable + + + + + net8.0-windows10.0.18362.0 + ARM64;x64 + None + false + + + + + net8.0 + + + + $(NETCoreSdkRuntimeIdentifier) + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/csharp/foundry-local/model-management-example/ModelManagementExample.sln b/samples/csharp/foundry-local/model-management-example/ModelManagementExample.sln new file mode 100644 index 000000000..870e3cf9a --- /dev/null +++ b/samples/csharp/foundry-local/model-management-example/ModelManagementExample.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModelManagementExample", "ModelManagementExample.csproj", "{9316B939-946C-4956-A4E7-9410017FD319}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {9316B939-946C-4956-A4E7-9410017FD319}.Debug|Any CPU.ActiveCfg = Debug|ARM64 + {9316B939-946C-4956-A4E7-9410017FD319}.Debug|Any CPU.Build.0 = Debug|ARM64 + {9316B939-946C-4956-A4E7-9410017FD319}.Debug|x64.ActiveCfg = Debug|x64 + {9316B939-946C-4956-A4E7-9410017FD319}.Debug|x64.Build.0 = Debug|x64 + {9316B939-946C-4956-A4E7-9410017FD319}.Debug|x86.ActiveCfg = Debug|ARM64 + {9316B939-946C-4956-A4E7-9410017FD319}.Debug|x86.Build.0 = Debug|ARM64 + {9316B939-946C-4956-A4E7-9410017FD319}.Release|Any CPU.ActiveCfg = Release|ARM64 + {9316B939-946C-4956-A4E7-9410017FD319}.Release|Any CPU.Build.0 = Release|ARM64 + {9316B939-946C-4956-A4E7-9410017FD319}.Release|x64.ActiveCfg = Release|x64 + {9316B939-946C-4956-A4E7-9410017FD319}.Release|x64.Build.0 = Release|x64 + {9316B939-946C-4956-A4E7-9410017FD319}.Release|x86.ActiveCfg = Release|ARM64 + {9316B939-946C-4956-A4E7-9410017FD319}.Release|x86.Build.0 = Release|ARM64 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/samples/csharp/foundry-local/model-management-example/Program.cs b/samples/csharp/foundry-local/model-management-example/Program.cs new file mode 100644 index 000000000..76beb89ff --- /dev/null +++ b/samples/csharp/foundry-local/model-management-example/Program.cs @@ -0,0 +1,155 @@ +using Microsoft.AI.Foundry.Local; +using Betalgo.Ranul.OpenAI.ObjectModels.RequestModels; +using System.Diagnostics; + +CancellationToken ct = new CancellationToken(); + +var config = new Configuration +{ + AppName = "foundry_local_samples", + LogLevel = Microsoft.AI.Foundry.Local.LogLevel.Information +}; + + +// Initialize the singleton instance. +await FoundryLocalManager.CreateAsync(config, Utils.GetAppLogger()); +var mgr = FoundryLocalManager.Instance; + + +// Download and register all execution providers. +var currentEp = ""; +await mgr.DownloadAndRegisterEpsAsync((epName, percent) => +{ + if (epName != currentEp) + { + if (currentEp != "") Console.WriteLine(); + currentEp = epName; + } + Console.Write($"\r {epName.PadRight(30)} {percent,6:F1}%"); +}); +if (currentEp != "") Console.WriteLine(); + + +// Model catalog operations +// In this section of the code we demonstrate the various model catalog operations +// Get the model catalog object +var catalog = await mgr.GetCatalogAsync(); + +// List available models +Console.WriteLine("Available models for your hardware:"); +var models = await catalog.ListModelsAsync(); +foreach (var availableModel in models) +{ + foreach (var variant in availableModel.Variants) + { + Console.WriteLine($" - Alias: {variant.Alias} (Id: {string.Join(", ", variant.Id)})"); + } +} + +// List cached models (i.e. downloaded models) from the catalog +var cachedModels = await catalog.GetCachedModelsAsync(); +Console.WriteLine("\nCached models:"); +foreach (var cachedModel in cachedModels) +{ + Console.WriteLine($"- {cachedModel.Alias} ({cachedModel.Id})"); +} + + +// Get a model using an alias from the catalog +var model = await catalog.GetModelAsync("qwen2.5-0.5b") ?? throw new Exception("Model not found"); + +// Models in Model.Variants are ordered by priority, with the highest priority first. +// The first downloaded model is selected by default. +// The highest priority is selected if no models have been downloaded. +// If the selected variant is not the highest priority, it means that Foundry Local +// has found a locally cached variant for you to improve performance (remove need to download). +Console.WriteLine("\nThe default selected model variant is: " + model.Id); +if (model.Id != model.Variants.First().Id) +{ + Debug.Assert(await model.IsCachedAsync()); + Console.WriteLine("The model variant was selected due to being locally cached."); +} + + +// OPTIONAL: `model` can be used directly with its currently selected variant. +// You can explicitly select (`model.SelectVariant`) or use a specific variant from `model.Variants` +// if you want more control over the device and/or execution provider used. +// +// Choices: +// - Use a model variant directly from the catalog if you know the variant Id +// - `var modelVariant = await catalog.GetModelVariantAsync("qwen2.5-0.5b-instruct-generic-gpu:3")` +// +// - Get the model variant from IModel.Variants +// - `var modelVariant = model.Variants.First(v => v.Id == "qwen2.5-0.5b-instruct-generic-cpu:4")` +// - `var modelVariant = model.Variants.First(v => v.Info.Runtime?.DeviceType == DeviceType.GPU)` +// - optional: update selected variant in `model` using `model.SelectVariant(modelVariant);` if you wish to use +// `model` in your code. + +// For this example we explicitly select the CPU variant, and call SelectVariant so all the following example code +// uses the `model` instance. It would be equally valid to use `modelVariant` directly. +Console.WriteLine("Selecting CPU variant of model"); +var modelVariant = model.Variants.First(v => v.Info.Runtime?.DeviceType == DeviceType.CPU); +model.SelectVariant(modelVariant); + + +// Download the model (the method skips download if already cached) +await model.DownloadAsync(progress => +{ + Console.Write($"\rDownloading model: {progress:F2}%"); + if (progress >= 100f) + { + Console.WriteLine(); + } +}); + +// Load the model +await model.LoadAsync(); + + +// List loaded models (i.e. in memory) from the catalog +var loadedModels = await catalog.GetLoadedModelsAsync(); +Console.WriteLine("\nLoaded models:"); +foreach (var loadedModel in loadedModels) +{ + Console.WriteLine($"- {loadedModel.Alias} ({loadedModel.Id})"); +} +Console.WriteLine(); + + +// Get a chat client +var chatClient = await model.GetChatClientAsync(); + +// Create a chat message +List messages = new() +{ + new ChatMessage { Role = "user", Content = "Why is the sky blue?" } +}; + +// You can adjust settings on the chat client +chatClient.Settings.Temperature = 0.7f; +chatClient.Settings.MaxTokens = 512; + +Console.WriteLine("Chat completion response:"); +var streamingResponse = chatClient.CompleteChatStreamingAsync(messages, ct); +await foreach (var chunk in streamingResponse) +{ + Console.Write(chunk.Choices[0].Message.Content); + Console.Out.Flush(); +} +Console.WriteLine(); +Console.WriteLine(); + +// Tidy up - unload the model +Console.WriteLine($"Unloading model {model.Id}..."); +await model.UnloadAsync(); +Console.WriteLine("Model unloaded."); + +// Show loaded models from the catalog after unload +loadedModels = await catalog.GetLoadedModelsAsync(); +Console.WriteLine("\nLoaded models after unload (will be empty):"); +foreach (var loadedModel in loadedModels) +{ + Console.WriteLine($"- {loadedModel.Alias} ({loadedModel.Id})"); +} +Console.WriteLine(); +Console.WriteLine("Sample complete."); \ No newline at end of file diff --git a/samples/csharp/foundry-local/model-management-example/README.md b/samples/csharp/foundry-local/model-management-example/README.md new file mode 100644 index 000000000..60dec9d7e --- /dev/null +++ b/samples/csharp/foundry-local/model-management-example/README.md @@ -0,0 +1,18 @@ +# Model Management (C#) + +Manage models, variant selection, and updates with the Foundry Local C# SDK. + +> Part of the [Foundry Local samples for C#](../). + +## Prerequisites + +- [Foundry Local](https://learn.microsoft.com/azure/foundry-local/) installed +- .NET 9 SDK + +## Run + +```bash +dotnet run +``` + +See the [Foundry Local documentation](https://learn.microsoft.com/azure/foundry-local/) for more details. diff --git a/samples/csharp/foundry-local/model-management-example/sample.yaml b/samples/csharp/foundry-local/model-management-example/sample.yaml new file mode 100644 index 000000000..beca7222f --- /dev/null +++ b/samples/csharp/foundry-local/model-management-example/sample.yaml @@ -0,0 +1,4 @@ +name: Model Management (C#) +description: Manage models, variant selection, and updates with the Foundry Local C# SDK. + +build: "dotnet restore ./*.csproj --source https://api.nuget.org/v3/index.json && dotnet build ./*.csproj --no-restore --verbosity minimal" diff --git a/samples/csharp/foundry-local/native-chat-completions/NativeChatCompletions.csproj b/samples/csharp/foundry-local/native-chat-completions/NativeChatCompletions.csproj new file mode 100644 index 000000000..4c2154b41 --- /dev/null +++ b/samples/csharp/foundry-local/native-chat-completions/NativeChatCompletions.csproj @@ -0,0 +1,47 @@ + + + + Exe + enable + enable + + + + + net8.0-windows10.0.18362.0 + ARM64;x64 + None + false + + + + + net8.0 + + + + $(NETCoreSdkRuntimeIdentifier) + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/csharp/foundry-local/native-chat-completions/NativeChatCompletions.sln b/samples/csharp/foundry-local/native-chat-completions/NativeChatCompletions.sln new file mode 100644 index 000000000..25f006ee9 --- /dev/null +++ b/samples/csharp/foundry-local/native-chat-completions/NativeChatCompletions.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NativeChatCompletions", "NativeChatCompletions.csproj", "{A53372CE-F7E1-4F09-B186-77F76E388659}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A53372CE-F7E1-4F09-B186-77F76E388659}.Debug|Any CPU.ActiveCfg = Debug|ARM64 + {A53372CE-F7E1-4F09-B186-77F76E388659}.Debug|Any CPU.Build.0 = Debug|ARM64 + {A53372CE-F7E1-4F09-B186-77F76E388659}.Debug|x64.ActiveCfg = Debug|x64 + {A53372CE-F7E1-4F09-B186-77F76E388659}.Debug|x64.Build.0 = Debug|x64 + {A53372CE-F7E1-4F09-B186-77F76E388659}.Debug|x86.ActiveCfg = Debug|ARM64 + {A53372CE-F7E1-4F09-B186-77F76E388659}.Debug|x86.Build.0 = Debug|ARM64 + {A53372CE-F7E1-4F09-B186-77F76E388659}.Release|Any CPU.ActiveCfg = Release|ARM64 + {A53372CE-F7E1-4F09-B186-77F76E388659}.Release|Any CPU.Build.0 = Release|ARM64 + {A53372CE-F7E1-4F09-B186-77F76E388659}.Release|x64.ActiveCfg = Release|x64 + {A53372CE-F7E1-4F09-B186-77F76E388659}.Release|x64.Build.0 = Release|x64 + {A53372CE-F7E1-4F09-B186-77F76E388659}.Release|x86.ActiveCfg = Release|ARM64 + {A53372CE-F7E1-4F09-B186-77F76E388659}.Release|x86.Build.0 = Release|ARM64 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/samples/csharp/foundry-local/native-chat-completions/Program.cs b/samples/csharp/foundry-local/native-chat-completions/Program.cs new file mode 100644 index 000000000..033786b1f --- /dev/null +++ b/samples/csharp/foundry-local/native-chat-completions/Program.cs @@ -0,0 +1,111 @@ +// +// +using Microsoft.AI.Foundry.Local; +using Betalgo.Ranul.OpenAI.ObjectModels.RequestModels; +// + +// +CancellationToken ct = new CancellationToken(); + +var config = new Configuration +{ + AppName = "foundry_local_samples", + LogLevel = Microsoft.AI.Foundry.Local.LogLevel.Information +}; + + +// Initialize the singleton instance. +await FoundryLocalManager.CreateAsync(config, Utils.GetAppLogger()); +var mgr = FoundryLocalManager.Instance; + + +// Discover available execution providers and their registration status. +var eps = mgr.DiscoverEps(); +int maxNameLen = 30; +Console.WriteLine("Available execution providers:"); +Console.WriteLine($" {"Name".PadRight(maxNameLen)} Registered"); +Console.WriteLine($" {new string('─', maxNameLen)} {"──────────"}"); +foreach (var ep in eps) +{ + Console.WriteLine($" {ep.Name.PadRight(maxNameLen)} {ep.IsRegistered}"); +} + +// Download and register all execution providers with per-EP progress. +// EP packages include dependencies and may be large. +// Download is only required again if a new version of the EP is released. +// For cross platform builds there is no dynamic EP download and this will return immediately. +Console.WriteLine("\nDownloading execution providers:"); +if (eps.Length > 0) +{ + string currentEp = ""; + await mgr.DownloadAndRegisterEpsAsync((epName, percent) => + { + if (epName != currentEp) + { + if (currentEp != "") + { + Console.WriteLine(); + } + currentEp = epName; + } + Console.Write($"\r {epName.PadRight(maxNameLen)} {percent,6:F1}%"); + }); + Console.WriteLine(); +} +else +{ + Console.WriteLine("No execution providers to download."); +} +// + + +// +// Get the model catalog +var catalog = await mgr.GetCatalogAsync(); + + +// Get a model using an alias. +var model = await catalog.GetModelAsync("qwen2.5-0.5b") ?? throw new Exception("Model not found"); + +// Download the model (the method skips download if already cached) +await model.DownloadAsync(progress => +{ + Console.Write($"\rDownloading model: {progress:F2}%"); + if (progress >= 100f) + { + Console.WriteLine(); + } +}); + +// Load the model +Console.Write($"Loading model {model.Id}..."); +await model.LoadAsync(); +Console.WriteLine("done."); +// + +// +// Get a chat client +var chatClient = await model.GetChatClientAsync(); + +// Create a chat message +List messages = new() +{ + new ChatMessage { Role = "user", Content = "Why is the sky blue?" } +}; + +// Get a streaming chat completion response +Console.WriteLine("Chat completion response:"); +var streamingResponse = chatClient.CompleteChatStreamingAsync(messages, ct); +await foreach (var chunk in streamingResponse) +{ + Console.Write(chunk.Choices[0].Message.Content); + Console.Out.Flush(); +} +Console.WriteLine(); +// + +// +// Tidy up - unload the model +await model.UnloadAsync(); +// +// \ No newline at end of file diff --git a/samples/csharp/foundry-local/native-chat-completions/README.md b/samples/csharp/foundry-local/native-chat-completions/README.md new file mode 100644 index 000000000..8f3fcc30c --- /dev/null +++ b/samples/csharp/foundry-local/native-chat-completions/README.md @@ -0,0 +1,18 @@ +# Native Chat Completions (C#) + +Initialize the SDK, download a model, and run chat completions. + +> Part of the [Foundry Local samples for C#](../). + +## Prerequisites + +- [Foundry Local](https://learn.microsoft.com/azure/foundry-local/) installed +- .NET 9 SDK + +## Run + +```bash +dotnet run +``` + +See the [Foundry Local documentation](https://learn.microsoft.com/azure/foundry-local/) for more details. diff --git a/samples/csharp/foundry-local/native-chat-completions/sample.yaml b/samples/csharp/foundry-local/native-chat-completions/sample.yaml new file mode 100644 index 000000000..25ff53f19 --- /dev/null +++ b/samples/csharp/foundry-local/native-chat-completions/sample.yaml @@ -0,0 +1,4 @@ +name: Native Chat Completions (C#) +description: Initialize the SDK, download a model, and run chat completions. + +build: "dotnet restore ./*.csproj --source https://api.nuget.org/v3/index.json && dotnet build ./*.csproj --no-restore --verbosity minimal" diff --git a/samples/csharp/foundry-local/tool-calling-foundry-local-sdk/Program.cs b/samples/csharp/foundry-local/tool-calling-foundry-local-sdk/Program.cs new file mode 100644 index 000000000..a40742331 --- /dev/null +++ b/samples/csharp/foundry-local/tool-calling-foundry-local-sdk/Program.cs @@ -0,0 +1,179 @@ +// +// +using Microsoft.AI.Foundry.Local; +using Betalgo.Ranul.OpenAI.ObjectModels.RequestModels; +using Betalgo.Ranul.OpenAI.ObjectModels.ResponseModels; +using Betalgo.Ranul.OpenAI.ObjectModels.SharedModels; +using System.Text.Json; +// + +// +CancellationToken ct = new CancellationToken(); + +var config = new Configuration +{ + AppName = "foundry_local_samples", + LogLevel = Microsoft.AI.Foundry.Local.LogLevel.Information +}; + + +// Initialize the singleton instance. +await FoundryLocalManager.CreateAsync(config, Utils.GetAppLogger()); +var mgr = FoundryLocalManager.Instance; + + +// Ensure that any Execution Provider (EP) downloads run and are completed. +// Download and register all execution providers. +var currentEp = ""; +await mgr.DownloadAndRegisterEpsAsync((epName, percent) => +{ + if (epName != currentEp) + { + if (currentEp != "") Console.WriteLine(); + currentEp = epName; + } + Console.Write($"\r {epName.PadRight(30)} {percent,6:F1}%"); +}); +if (currentEp != "") Console.WriteLine(); +// + + +// +// Get the model catalog +var catalog = await mgr.GetCatalogAsync(); + + +// Get a model using an alias. +var model = await catalog.GetModelAsync("qwen2.5-0.5b") ?? throw new Exception("Model not found"); + + +// Download the model (the method skips download if already cached) +await model.DownloadAsync(progress => +{ + Console.Write($"\rDownloading model: {progress:F2}%"); + if (progress >= 100f) + { + Console.WriteLine(); + } +}); + + +// Load the model +Console.Write($"Loading model {model.Id}..."); +await model.LoadAsync(); +Console.WriteLine("done."); +// + + +// Get a chat client +var chatClient = await model.GetChatClientAsync(); +chatClient.Settings.ToolChoice = ToolChoice.Required; // Force the model to make a tool call + + +// Prepare messages +List messages = +[ + new ChatMessage { Role = "system", Content = "You are a helpful AI assistant. If necessary, you can use any provided tools to answer the question." }, + new ChatMessage { Role = "user", Content = "What is the answer to 7 multiplied by 6?" } +]; + + +// +// Prepare tools +List tools = +[ + new ToolDefinition + { + Type = "function", + Function = new FunctionDefinition() + { + Name = "multiply_numbers", + Description = "A tool for multiplying two numbers.", + Parameters = new PropertyDefinition() + { + Type = "object", + Properties = new Dictionary() + { + { "first", new PropertyDefinition() { Type = "integer", Description = "The first number in the operation" } }, + { "second", new PropertyDefinition() { Type = "integer", Description = "The second number in the operation" } } + }, + Required = ["first", "second"] + } + } + } +]; +// + + +// +// Get a streaming chat completion response +var toolCallResponses = new List(); +Console.WriteLine("Chat completion response:"); +var streamingResponse = chatClient.CompleteChatStreamingAsync(messages, tools, ct); +await foreach (var chunk in streamingResponse) +{ + var content = chunk.Choices[0].Message.Content; + Console.Write(content); + Console.Out.Flush(); + + if (chunk.Choices[0].FinishReason == "tool_calls") + { + toolCallResponses.Add(chunk); + } +} +Console.WriteLine(); + + +// Invoke tools called and append responses to the chat +foreach (var chunk in toolCallResponses) +{ + var call = chunk?.Choices[0].Message.ToolCalls?[0].FunctionCall; + if (call?.Name == "multiply_numbers") + { + var arguments = JsonSerializer.Deserialize>(call.Arguments!)!; + var first = arguments["first"]; + var second = arguments["second"]; + + Console.WriteLine($"\nInvoking tool: {call?.Name} with arguments {first} and {second}"); + var result = Utils.MultiplyNumbers(first, second); + Console.WriteLine($"Tool response: {result.ToString()}"); + + var response = new ChatMessage + { + Role = "tool", + ToolCallId = chunk!.Choices[0].Message.ToolCalls![0].Id, + Content = result.ToString(), + }; + messages.Add(response); + } +} +Console.WriteLine("\nTool calls completed. Prompting model to continue conversation...\n"); + + +// Prompt the model to continue the conversation after the tool call +messages.Add(new ChatMessage { Role = "system", Content = "Respond only with the answer generated by the tool." }); + + +// Set tool calling back to auto so that the model can decide whether to call +// the tool again or continue the conversation based on the new user prompt +chatClient.Settings.ToolChoice = ToolChoice.Auto; + + +// Run the next turn of the conversation +Console.WriteLine("Chat completion response:"); +streamingResponse = chatClient.CompleteChatStreamingAsync(messages, tools, ct); +await foreach (var chunk in streamingResponse) +{ + var content = chunk.Choices[0].Message.Content; + Console.Write(content); + Console.Out.Flush(); +} +Console.WriteLine(); +// + + +// +// Tidy up - unload the model +await model.UnloadAsync(); +// +// \ No newline at end of file diff --git a/samples/csharp/foundry-local/tool-calling-foundry-local-sdk/README.md b/samples/csharp/foundry-local/tool-calling-foundry-local-sdk/README.md new file mode 100644 index 000000000..e886940d1 --- /dev/null +++ b/samples/csharp/foundry-local/tool-calling-foundry-local-sdk/README.md @@ -0,0 +1,18 @@ +# Tool Calling with Foundry Local SDK (C#) + +Use tool calling with native chat completions. + +> Part of the [Foundry Local samples for C#](../). + +## Prerequisites + +- [Foundry Local](https://learn.microsoft.com/azure/foundry-local/) installed +- .NET 9 SDK + +## Run + +```bash +dotnet run +``` + +See the [Foundry Local documentation](https://learn.microsoft.com/azure/foundry-local/) for more details. diff --git a/samples/csharp/foundry-local/tool-calling-foundry-local-sdk/ToolCallingFoundryLocalSdk.csproj b/samples/csharp/foundry-local/tool-calling-foundry-local-sdk/ToolCallingFoundryLocalSdk.csproj new file mode 100644 index 000000000..4c2154b41 --- /dev/null +++ b/samples/csharp/foundry-local/tool-calling-foundry-local-sdk/ToolCallingFoundryLocalSdk.csproj @@ -0,0 +1,47 @@ + + + + Exe + enable + enable + + + + + net8.0-windows10.0.18362.0 + ARM64;x64 + None + false + + + + + net8.0 + + + + $(NETCoreSdkRuntimeIdentifier) + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/csharp/foundry-local/tool-calling-foundry-local-sdk/ToolCallingFoundryLocalSdk.sln b/samples/csharp/foundry-local/tool-calling-foundry-local-sdk/ToolCallingFoundryLocalSdk.sln new file mode 100644 index 000000000..326d78a6e --- /dev/null +++ b/samples/csharp/foundry-local/tool-calling-foundry-local-sdk/ToolCallingFoundryLocalSdk.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ToolCallingFoundryLocalSdk", "ToolCallingFoundryLocalSdk.csproj", "{7B40637D-D7E3-4A95-9B57-8D0EF84C8532}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {7B40637D-D7E3-4A95-9B57-8D0EF84C8532}.Debug|Any CPU.ActiveCfg = Debug|ARM64 + {7B40637D-D7E3-4A95-9B57-8D0EF84C8532}.Debug|Any CPU.Build.0 = Debug|ARM64 + {7B40637D-D7E3-4A95-9B57-8D0EF84C8532}.Debug|x64.ActiveCfg = Debug|x64 + {7B40637D-D7E3-4A95-9B57-8D0EF84C8532}.Debug|x64.Build.0 = Debug|x64 + {7B40637D-D7E3-4A95-9B57-8D0EF84C8532}.Debug|x86.ActiveCfg = Debug|ARM64 + {7B40637D-D7E3-4A95-9B57-8D0EF84C8532}.Debug|x86.Build.0 = Debug|ARM64 + {7B40637D-D7E3-4A95-9B57-8D0EF84C8532}.Release|Any CPU.ActiveCfg = Release|ARM64 + {7B40637D-D7E3-4A95-9B57-8D0EF84C8532}.Release|Any CPU.Build.0 = Release|ARM64 + {7B40637D-D7E3-4A95-9B57-8D0EF84C8532}.Release|x64.ActiveCfg = Release|x64 + {7B40637D-D7E3-4A95-9B57-8D0EF84C8532}.Release|x64.Build.0 = Release|x64 + {7B40637D-D7E3-4A95-9B57-8D0EF84C8532}.Release|x86.ActiveCfg = Release|ARM64 + {7B40637D-D7E3-4A95-9B57-8D0EF84C8532}.Release|x86.Build.0 = Release|ARM64 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/samples/csharp/foundry-local/tool-calling-foundry-local-sdk/sample.yaml b/samples/csharp/foundry-local/tool-calling-foundry-local-sdk/sample.yaml new file mode 100644 index 000000000..d8b9c23c3 --- /dev/null +++ b/samples/csharp/foundry-local/tool-calling-foundry-local-sdk/sample.yaml @@ -0,0 +1,4 @@ +name: Tool Calling with Foundry Local SDK (C#) +description: Use tool calling with native chat completions. + +build: "dotnet restore ./*.csproj --source https://api.nuget.org/v3/index.json && dotnet build ./*.csproj --no-restore --verbosity minimal" diff --git a/samples/csharp/foundry-local/tool-calling-foundry-local-web-server/Program.cs b/samples/csharp/foundry-local/tool-calling-foundry-local-web-server/Program.cs new file mode 100644 index 000000000..6644a438b --- /dev/null +++ b/samples/csharp/foundry-local/tool-calling-foundry-local-web-server/Program.cs @@ -0,0 +1,190 @@ +// +using Microsoft.AI.Foundry.Local; +using OpenAI; +using OpenAI.Chat; +using System.ClientModel; +using System.Text.Json; + +var config = new Configuration +{ + AppName = "foundry_local_samples", + LogLevel = Microsoft.AI.Foundry.Local.LogLevel.Information, + Web = new Configuration.WebService + { + Urls = "http://127.0.0.1:52495" + } +}; + + +// Initialize the singleton instance. +await FoundryLocalManager.CreateAsync(config, Utils.GetAppLogger()); +var mgr = FoundryLocalManager.Instance; + + +// Download and register all execution providers. +var currentEp = ""; +await mgr.DownloadAndRegisterEpsAsync((epName, percent) => +{ + if (epName != currentEp) + { + if (currentEp != "") Console.WriteLine(); + currentEp = epName; + } + Console.Write($"\r {epName.PadRight(30)} {percent,6:F1}%"); +}); +if (currentEp != "") Console.WriteLine(); + + +// Get the model catalog +var catalog = await mgr.GetCatalogAsync(); + + +// Get a model using an alias +var model = await catalog.GetModelAsync("qwen2.5-0.5b") ?? throw new Exception("Model not found"); +// Download the model (the method skips download if already cached) +await model.DownloadAsync(progress => +{ + Console.Write($"\rDownloading model: {progress:F2}%"); + if (progress >= 100f) + { + Console.WriteLine(); + } +}); + + +// Load the model +Console.Write($"Loading model {model.Id}..."); +await model.LoadAsync(); +Console.WriteLine("done."); + + +// Start the web service +Console.Write($"Starting web service on {config.Web.Urls}..."); +await mgr.StartWebServiceAsync(); +Console.WriteLine("done."); + + +// <<<<<< OPEN AI SDK USAGE >>>>>> +// Use the OpenAI SDK to call the local Foundry web service + +ApiKeyCredential key = new ApiKeyCredential("notneeded"); +OpenAIClient client = new OpenAIClient(key, new OpenAIClientOptions +{ + Endpoint = new Uri(config.Web.Urls + "/v1"), +}); + + +// Get chat client +var chatClient = client.GetChatClient(model.Id); + + +// Prepare messages +var messages = new List +{ + ChatMessage.CreateSystemMessage("You are a helpful AI assistant. If necessary, you can use any provided tools to answer the question."), + ChatMessage.CreateUserMessage("What is the answer to 7 multiplied by 6?") +}; + + +// Prepare tools +var tools = new List +{ + ChatTool.CreateFunctionTool( + functionName: "multiply_numbers", + functionDescription: "A tool for multiplying two numbers.", + functionParameters: BinaryData.FromString(""" + { + "type": "object", + "properties": { + "first": { "type": "number", "description": "The first number in the operation" }, + "second": { "type": "number", "description": "The second number in the operation" } + }, + "required": ["first", "second"] + } + """) + ) +}; + + +// Prepare chat completion options +var options = new ChatCompletionOptions +{ + ToolChoice = ChatToolChoice.CreateRequiredChoice() // Force the model to make a tool call +}; +foreach (var tool in tools) +{ + options.Tools.Add(tool); +} + + +// Get a streaming chat completion response +var completionUpdates = chatClient.CompleteChatStreaming(messages, options); +var toolCalls = new List(); +Console.Write($"[ASSISTANT]: "); +foreach (var completionUpdate in completionUpdates) +{ + if (completionUpdate.ContentUpdate.Count > 0) + { + Console.Write(completionUpdate.ContentUpdate[0].Text); + } + + if (completionUpdate.FinishReason == ChatFinishReason.ToolCalls) + { + foreach (var toolCall in completionUpdate.ToolCallUpdates) + { + toolCalls.Add(toolCall); + } + } +} +Console.WriteLine(); + + +// Invoke tools called and append responses to the chat +foreach (var toolCall in toolCalls) +{ + if (toolCall.FunctionName == "multiply_numbers") + { + var arguments = JsonDocument.Parse(toolCall.FunctionArgumentsUpdate.ToString()).RootElement; + var first = arguments.GetProperty("first").GetInt32(); + var second = arguments.GetProperty("second").GetInt32(); + + Console.WriteLine($"\nInvoking tool: {toolCall.FunctionName} with arguments {first} and {second}"); + var result = Utils.MultiplyNumbers(first, second); + Console.WriteLine($"Tool response: {result.ToString()}"); + + messages.Add(ChatMessage.CreateToolMessage(toolCallId: "abcd1234", content: result.ToString())); + } +} +Console.WriteLine("\nTool calls completed. Prompting model to continue conversation...\n"); + + +// Prompt the model to continue the conversation after the tool call +messages.Add(ChatMessage.CreateSystemMessage("Respond only with the answer generated by the tool.")); + + +// Set tool calling back to auto so that the model can decide whether to call +// the tool again or continue the conversation based on the new user prompt +options.ToolChoice = ChatToolChoice.CreateAutoChoice(); + + +// Run the next turn of the conversation +Console.WriteLine("Chat completion response:"); +completionUpdates = chatClient.CompleteChatStreaming(messages, options); +Console.Write($"[ASSISTANT]: "); +foreach (var completionUpdate in completionUpdates) +{ + if (completionUpdate.ContentUpdate.Count > 0) + { + Console.Write(completionUpdate.ContentUpdate[0].Text); + } +} +Console.WriteLine(); + +// <<<<<< END OPEN AI SDK USAGE >>>>>> + + +// Tidy up +// Stop the web service and unload model +await mgr.StopWebServiceAsync(); +await model.UnloadAsync(); +// \ No newline at end of file diff --git a/samples/csharp/foundry-local/tool-calling-foundry-local-web-server/README.md b/samples/csharp/foundry-local/tool-calling-foundry-local-web-server/README.md new file mode 100644 index 000000000..162e523c2 --- /dev/null +++ b/samples/csharp/foundry-local/tool-calling-foundry-local-web-server/README.md @@ -0,0 +1,18 @@ +# Tool Calling with Foundry Local Web Server (C#) + +Use tool calling with the local OpenAI-compatible web server. + +> Part of the [Foundry Local samples for C#](../). + +## Prerequisites + +- [Foundry Local](https://learn.microsoft.com/azure/foundry-local/) installed +- .NET 9 SDK + +## Run + +```bash +dotnet run +``` + +See the [Foundry Local documentation](https://learn.microsoft.com/azure/foundry-local/) for more details. diff --git a/samples/csharp/foundry-local/tool-calling-foundry-local-web-server/ToolCallingFoundryLocalWebServer.csproj b/samples/csharp/foundry-local/tool-calling-foundry-local-web-server/ToolCallingFoundryLocalWebServer.csproj new file mode 100644 index 000000000..fc6cbf2a7 --- /dev/null +++ b/samples/csharp/foundry-local/tool-calling-foundry-local-web-server/ToolCallingFoundryLocalWebServer.csproj @@ -0,0 +1,51 @@ + + + + Exe + enable + enable + + + + + net8.0-windows10.0.18362.0 + ARM64;x64 + None + false + + + + + net8.0 + + + + $(NETCoreSdkRuntimeIdentifier) + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/csharp/foundry-local/tool-calling-foundry-local-web-server/ToolCallingFoundryLocalWebServer.sln b/samples/csharp/foundry-local/tool-calling-foundry-local-web-server/ToolCallingFoundryLocalWebServer.sln new file mode 100644 index 000000000..e659dca6c --- /dev/null +++ b/samples/csharp/foundry-local/tool-calling-foundry-local-web-server/ToolCallingFoundryLocalWebServer.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ToolCallingFoundryLocalWebServer", "ToolCallingFoundryLocalWebServer.csproj", "{F9BD2479-A235-4BBF-A722-DF180A076143}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {F9BD2479-A235-4BBF-A722-DF180A076143}.Debug|Any CPU.ActiveCfg = Debug|ARM64 + {F9BD2479-A235-4BBF-A722-DF180A076143}.Debug|Any CPU.Build.0 = Debug|ARM64 + {F9BD2479-A235-4BBF-A722-DF180A076143}.Debug|x64.ActiveCfg = Debug|x64 + {F9BD2479-A235-4BBF-A722-DF180A076143}.Debug|x64.Build.0 = Debug|x64 + {F9BD2479-A235-4BBF-A722-DF180A076143}.Debug|x86.ActiveCfg = Debug|ARM64 + {F9BD2479-A235-4BBF-A722-DF180A076143}.Debug|x86.Build.0 = Debug|ARM64 + {F9BD2479-A235-4BBF-A722-DF180A076143}.Release|Any CPU.ActiveCfg = Release|ARM64 + {F9BD2479-A235-4BBF-A722-DF180A076143}.Release|Any CPU.Build.0 = Release|ARM64 + {F9BD2479-A235-4BBF-A722-DF180A076143}.Release|x64.ActiveCfg = Release|x64 + {F9BD2479-A235-4BBF-A722-DF180A076143}.Release|x64.Build.0 = Release|x64 + {F9BD2479-A235-4BBF-A722-DF180A076143}.Release|x86.ActiveCfg = Release|ARM64 + {F9BD2479-A235-4BBF-A722-DF180A076143}.Release|x86.Build.0 = Release|ARM64 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/samples/csharp/foundry-local/tool-calling-foundry-local-web-server/sample.yaml b/samples/csharp/foundry-local/tool-calling-foundry-local-web-server/sample.yaml new file mode 100644 index 000000000..4b445bc54 --- /dev/null +++ b/samples/csharp/foundry-local/tool-calling-foundry-local-web-server/sample.yaml @@ -0,0 +1,4 @@ +name: Tool Calling with Foundry Local Web Server (C#) +description: Use tool calling with the local OpenAI-compatible web server. + +build: "dotnet restore ./*.csproj --source https://api.nuget.org/v3/index.json && dotnet build ./*.csproj --no-restore --verbosity minimal" diff --git a/samples/csharp/foundry-local/tutorial-chat-assistant/Program.cs b/samples/csharp/foundry-local/tutorial-chat-assistant/Program.cs new file mode 100644 index 000000000..d06de6a5e --- /dev/null +++ b/samples/csharp/foundry-local/tutorial-chat-assistant/Program.cs @@ -0,0 +1,114 @@ +// +// +using Microsoft.AI.Foundry.Local; +using Betalgo.Ranul.OpenAI.ObjectModels.RequestModels; +using Microsoft.Extensions.Logging; +// + +// +CancellationToken ct = CancellationToken.None; + +var config = new Configuration +{ + AppName = "foundry_local_samples", + LogLevel = Microsoft.AI.Foundry.Local.LogLevel.Information +}; + +using var loggerFactory = LoggerFactory.Create(builder => +{ + builder.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Information); +}); +var logger = loggerFactory.CreateLogger(); + +// Initialize the singleton instance +await FoundryLocalManager.CreateAsync(config, logger); +var mgr = FoundryLocalManager.Instance; + +// Download and register all execution providers. +var currentEp = ""; +await mgr.DownloadAndRegisterEpsAsync((epName, percent) => +{ + if (epName != currentEp) + { + if (currentEp != "") Console.WriteLine(); + currentEp = epName; + } + Console.Write($"\r {epName.PadRight(30)} {percent,6:F1}%"); +}); +if (currentEp != "") Console.WriteLine(); + +// Select and load a model from the catalog +var catalog = await mgr.GetCatalogAsync(); +var model = await catalog.GetModelAsync("qwen2.5-0.5b") + ?? throw new Exception("Model not found"); + +await model.DownloadAsync(progress => +{ + Console.Write($"\rDownloading model: {progress:F2}%"); + if (progress >= 100f) Console.WriteLine(); +}); + +await model.LoadAsync(); +Console.WriteLine("Model loaded and ready."); + +// Get a chat client +var chatClient = await model.GetChatClientAsync(); +// + +// +// Start the conversation with a system prompt +var messages = new List +{ + new ChatMessage + { + Role = "system", + Content = "You are a helpful, friendly assistant. Keep your responses " + + "concise and conversational. If you don't know something, say so." + } +}; +// + +Console.WriteLine("\nChat assistant ready! Type 'quit' to exit.\n"); + +// +while (true) +{ + Console.Write("You: "); + var userInput = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(userInput) || + userInput.Equals("quit", StringComparison.OrdinalIgnoreCase) || + userInput.Equals("exit", StringComparison.OrdinalIgnoreCase)) + { + break; + } + + // Add the user's message to conversation history + messages.Add(new ChatMessage { Role = "user", Content = userInput }); + + // + // Stream the response token by token + Console.Write("Assistant: "); + var fullResponse = string.Empty; + var streamingResponse = chatClient.CompleteChatStreamingAsync(messages, ct); + await foreach (var chunk in streamingResponse) + { + var content = chunk.Choices[0].Message.Content; + if (!string.IsNullOrEmpty(content)) + { + Console.Write(content); + Console.Out.Flush(); + fullResponse += content; + } + } + Console.WriteLine("\n"); + // + + // Add the complete response to conversation history + messages.Add(new ChatMessage { Role = "assistant", Content = fullResponse }); +} +// + +// Clean up - unload the model +await model.UnloadAsync(); +Console.WriteLine("Model unloaded. Goodbye!"); +// diff --git a/samples/csharp/foundry-local/tutorial-chat-assistant/README.md b/samples/csharp/foundry-local/tutorial-chat-assistant/README.md new file mode 100644 index 000000000..a87dd48d8 --- /dev/null +++ b/samples/csharp/foundry-local/tutorial-chat-assistant/README.md @@ -0,0 +1,18 @@ +# Tutorial: Chat Assistant (C#) + +Build an interactive multi-turn chat assistant (tutorial). + +> Part of the [Foundry Local samples for C#](../). + +## Prerequisites + +- [Foundry Local](https://learn.microsoft.com/azure/foundry-local/) installed +- .NET 9 SDK + +## Run + +```bash +dotnet run +``` + +See the [Foundry Local documentation](https://learn.microsoft.com/azure/foundry-local/) for more details. diff --git a/samples/csharp/foundry-local/tutorial-chat-assistant/TutorialChatAssistant.csproj b/samples/csharp/foundry-local/tutorial-chat-assistant/TutorialChatAssistant.csproj new file mode 100644 index 000000000..d1e25e90a --- /dev/null +++ b/samples/csharp/foundry-local/tutorial-chat-assistant/TutorialChatAssistant.csproj @@ -0,0 +1,49 @@ + + + + Exe + enable + enable + + + + + net8.0-windows10.0.18362.0 + ARM64;x64 + None + false + + + + + net8.0 + + + + $(NETCoreSdkRuntimeIdentifier) + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/csharp/foundry-local/tutorial-chat-assistant/TutorialChatAssistant.sln b/samples/csharp/foundry-local/tutorial-chat-assistant/TutorialChatAssistant.sln new file mode 100644 index 000000000..a3256724c --- /dev/null +++ b/samples/csharp/foundry-local/tutorial-chat-assistant/TutorialChatAssistant.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TutorialChatAssistant", "TutorialChatAssistant.csproj", "{5D5778BD-B40A-4D9E-BC2F-65AD50EE6F94}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5D5778BD-B40A-4D9E-BC2F-65AD50EE6F94}.Debug|Any CPU.ActiveCfg = Debug|ARM64 + {5D5778BD-B40A-4D9E-BC2F-65AD50EE6F94}.Debug|Any CPU.Build.0 = Debug|ARM64 + {5D5778BD-B40A-4D9E-BC2F-65AD50EE6F94}.Debug|x64.ActiveCfg = Debug|x64 + {5D5778BD-B40A-4D9E-BC2F-65AD50EE6F94}.Debug|x64.Build.0 = Debug|x64 + {5D5778BD-B40A-4D9E-BC2F-65AD50EE6F94}.Debug|x86.ActiveCfg = Debug|ARM64 + {5D5778BD-B40A-4D9E-BC2F-65AD50EE6F94}.Debug|x86.Build.0 = Debug|ARM64 + {5D5778BD-B40A-4D9E-BC2F-65AD50EE6F94}.Release|Any CPU.ActiveCfg = Release|ARM64 + {5D5778BD-B40A-4D9E-BC2F-65AD50EE6F94}.Release|Any CPU.Build.0 = Release|ARM64 + {5D5778BD-B40A-4D9E-BC2F-65AD50EE6F94}.Release|x64.ActiveCfg = Release|x64 + {5D5778BD-B40A-4D9E-BC2F-65AD50EE6F94}.Release|x64.Build.0 = Release|x64 + {5D5778BD-B40A-4D9E-BC2F-65AD50EE6F94}.Release|x86.ActiveCfg = Release|ARM64 + {5D5778BD-B40A-4D9E-BC2F-65AD50EE6F94}.Release|x86.Build.0 = Release|ARM64 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/samples/csharp/foundry-local/tutorial-chat-assistant/sample.yaml b/samples/csharp/foundry-local/tutorial-chat-assistant/sample.yaml new file mode 100644 index 000000000..7ee687293 --- /dev/null +++ b/samples/csharp/foundry-local/tutorial-chat-assistant/sample.yaml @@ -0,0 +1,4 @@ +name: "Tutorial: Chat Assistant (C#)" +description: Build an interactive multi-turn chat assistant (tutorial). + +build: "dotnet restore ./*.csproj --source https://api.nuget.org/v3/index.json && dotnet build ./*.csproj --no-restore --verbosity minimal" diff --git a/samples/csharp/foundry-local/tutorial-document-summarizer/Program.cs b/samples/csharp/foundry-local/tutorial-document-summarizer/Program.cs new file mode 100644 index 000000000..333d5c964 --- /dev/null +++ b/samples/csharp/foundry-local/tutorial-document-summarizer/Program.cs @@ -0,0 +1,122 @@ +// +// +using Microsoft.AI.Foundry.Local; +using Betalgo.Ranul.OpenAI.ObjectModels.RequestModels; +using Microsoft.Extensions.Logging; +// + +// +CancellationToken ct = CancellationToken.None; + +var config = new Configuration +{ + AppName = "foundry_local_samples", + LogLevel = Microsoft.AI.Foundry.Local.LogLevel.Information +}; + +using var loggerFactory = LoggerFactory.Create(builder => +{ + builder.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Information); +}); +var logger = loggerFactory.CreateLogger(); + +// Initialize the singleton instance +await FoundryLocalManager.CreateAsync(config, logger); +var mgr = FoundryLocalManager.Instance; + +// Download and register all execution providers. +var currentEp = ""; +await mgr.DownloadAndRegisterEpsAsync((epName, percent) => +{ + if (epName != currentEp) + { + if (currentEp != "") Console.WriteLine(); + currentEp = epName; + } + Console.Write($"\r {epName.PadRight(30)} {percent,6:F1}%"); +}); +if (currentEp != "") Console.WriteLine(); + +// Select and load a model from the catalog +var catalog = await mgr.GetCatalogAsync(); +var model = await catalog.GetModelAsync("qwen2.5-0.5b") + ?? throw new Exception("Model not found"); + +await model.DownloadAsync(progress => +{ + Console.Write($"\rDownloading model: {progress:F2}%"); + if (progress >= 100f) Console.WriteLine(); +}); + +await model.LoadAsync(); +Console.WriteLine("Model loaded and ready.\n"); + +// Get a chat client +var chatClient = await model.GetChatClientAsync(); +// + +// +var systemPrompt = + "Summarize the following document into concise bullet points. " + + "Focus on the key points and main ideas."; + +// +var target = args.Length > 0 ? args[0] : "document.txt"; +// + +if (Directory.Exists(target)) +{ + await SummarizeDirectoryAsync(chatClient, target, systemPrompt, ct); +} +else +{ + Console.WriteLine($"--- {Path.GetFileName(target)} ---"); + await SummarizeFileAsync(chatClient, target, systemPrompt, ct); +} +// + +// Clean up +await model.UnloadAsync(); +Console.WriteLine("\nModel unloaded. Done!"); + +async Task SummarizeFileAsync( + dynamic client, + string filePath, + string prompt, + CancellationToken token) +{ + var fileContent = await File.ReadAllTextAsync(filePath, token); + var messages = new List + { + new ChatMessage { Role = "system", Content = prompt }, + new ChatMessage { Role = "user", Content = fileContent } + }; + + var response = await client.CompleteChatAsync(messages, token); + Console.WriteLine(response.Choices[0].Message.Content); +} + +async Task SummarizeDirectoryAsync( + dynamic client, + string directory, + string prompt, + CancellationToken token) +{ + var txtFiles = Directory.GetFiles(directory, "*.txt") + .OrderBy(f => f) + .ToArray(); + + if (txtFiles.Length == 0) + { + Console.WriteLine($"No .txt files found in {directory}"); + return; + } + + foreach (var txtFile in txtFiles) + { + Console.WriteLine($"--- {Path.GetFileName(txtFile)} ---"); + await SummarizeFileAsync(client, txtFile, prompt, token); + Console.WriteLine(); + } +} +// diff --git a/samples/csharp/foundry-local/tutorial-document-summarizer/README.md b/samples/csharp/foundry-local/tutorial-document-summarizer/README.md new file mode 100644 index 000000000..6540b659b --- /dev/null +++ b/samples/csharp/foundry-local/tutorial-document-summarizer/README.md @@ -0,0 +1,18 @@ +# Tutorial: Document Summarizer (C#) + +Summarize documents with AI (tutorial). + +> Part of the [Foundry Local samples for C#](../). + +## Prerequisites + +- [Foundry Local](https://learn.microsoft.com/azure/foundry-local/) installed +- .NET 9 SDK + +## Run + +```bash +dotnet run +``` + +See the [Foundry Local documentation](https://learn.microsoft.com/azure/foundry-local/) for more details. diff --git a/samples/csharp/foundry-local/tutorial-document-summarizer/TutorialDocumentSummarizer.csproj b/samples/csharp/foundry-local/tutorial-document-summarizer/TutorialDocumentSummarizer.csproj new file mode 100644 index 000000000..d1e25e90a --- /dev/null +++ b/samples/csharp/foundry-local/tutorial-document-summarizer/TutorialDocumentSummarizer.csproj @@ -0,0 +1,49 @@ + + + + Exe + enable + enable + + + + + net8.0-windows10.0.18362.0 + ARM64;x64 + None + false + + + + + net8.0 + + + + $(NETCoreSdkRuntimeIdentifier) + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/csharp/foundry-local/tutorial-document-summarizer/TutorialDocumentSummarizer.sln b/samples/csharp/foundry-local/tutorial-document-summarizer/TutorialDocumentSummarizer.sln new file mode 100644 index 000000000..4207c8818 --- /dev/null +++ b/samples/csharp/foundry-local/tutorial-document-summarizer/TutorialDocumentSummarizer.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TutorialDocumentSummarizer", "TutorialDocumentSummarizer.csproj", "{6868D03F-BD8E-46ED-9A5B-95346A3810A4}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {6868D03F-BD8E-46ED-9A5B-95346A3810A4}.Debug|Any CPU.ActiveCfg = Debug|ARM64 + {6868D03F-BD8E-46ED-9A5B-95346A3810A4}.Debug|Any CPU.Build.0 = Debug|ARM64 + {6868D03F-BD8E-46ED-9A5B-95346A3810A4}.Debug|x64.ActiveCfg = Debug|x64 + {6868D03F-BD8E-46ED-9A5B-95346A3810A4}.Debug|x64.Build.0 = Debug|x64 + {6868D03F-BD8E-46ED-9A5B-95346A3810A4}.Debug|x86.ActiveCfg = Debug|ARM64 + {6868D03F-BD8E-46ED-9A5B-95346A3810A4}.Debug|x86.Build.0 = Debug|ARM64 + {6868D03F-BD8E-46ED-9A5B-95346A3810A4}.Release|Any CPU.ActiveCfg = Release|ARM64 + {6868D03F-BD8E-46ED-9A5B-95346A3810A4}.Release|Any CPU.Build.0 = Release|ARM64 + {6868D03F-BD8E-46ED-9A5B-95346A3810A4}.Release|x64.ActiveCfg = Release|x64 + {6868D03F-BD8E-46ED-9A5B-95346A3810A4}.Release|x64.Build.0 = Release|x64 + {6868D03F-BD8E-46ED-9A5B-95346A3810A4}.Release|x86.ActiveCfg = Release|ARM64 + {6868D03F-BD8E-46ED-9A5B-95346A3810A4}.Release|x86.Build.0 = Release|ARM64 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/samples/csharp/foundry-local/tutorial-document-summarizer/sample.yaml b/samples/csharp/foundry-local/tutorial-document-summarizer/sample.yaml new file mode 100644 index 000000000..318cede6a --- /dev/null +++ b/samples/csharp/foundry-local/tutorial-document-summarizer/sample.yaml @@ -0,0 +1,4 @@ +name: "Tutorial: Document Summarizer (C#)" +description: Summarize documents with AI (tutorial). + +build: "dotnet restore ./*.csproj --source https://api.nuget.org/v3/index.json && dotnet build ./*.csproj --no-restore --verbosity minimal" diff --git a/samples/csharp/foundry-local/tutorial-tool-calling/Program.cs b/samples/csharp/foundry-local/tutorial-tool-calling/Program.cs new file mode 100644 index 000000000..5ae60419a --- /dev/null +++ b/samples/csharp/foundry-local/tutorial-tool-calling/Program.cs @@ -0,0 +1,241 @@ +// +// +using System.Text.Json; +using Microsoft.AI.Foundry.Local; +using Betalgo.Ranul.OpenAI.ObjectModels.RequestModels; +using Betalgo.Ranul.OpenAI.ObjectModels.ResponseModels; +using Betalgo.Ranul.OpenAI.ObjectModels.SharedModels; +using Microsoft.Extensions.Logging; +// + +CancellationToken ct = CancellationToken.None; + +// +// --- Tool definitions --- +List tools = +[ + new ToolDefinition + { + Type = "function", + Function = new FunctionDefinition() + { + Name = "get_weather", + Description = "Get the current weather for a location", + Parameters = new PropertyDefinition() + { + Type = "object", + Properties = new Dictionary() + { + { "location", new PropertyDefinition() { Type = "string", Description = "The city or location" } }, + { "unit", new PropertyDefinition() { Type = "string", Description = "Temperature unit (celsius or fahrenheit)" } } + }, + Required = ["location"] + } + } + }, + new ToolDefinition + { + Type = "function", + Function = new FunctionDefinition() + { + Name = "calculate", + Description = "Perform a math calculation", + Parameters = new PropertyDefinition() + { + Type = "object", + Properties = new Dictionary() + { + { "expression", new PropertyDefinition() { Type = "string", Description = "The math expression to evaluate" } } + }, + Required = ["expression"] + } + } + } +]; + +// --- Tool implementations --- +string ExecuteTool(string functionName, JsonElement arguments) +{ + switch (functionName) + { + case "get_weather": + var location = arguments.GetProperty("location") + .GetString() ?? "unknown"; + var unit = arguments.TryGetProperty("unit", out var u) + ? u.GetString() ?? "celsius" + : "celsius"; + var temp = unit == "celsius" ? 22 : 72; + return JsonSerializer.Serialize(new + { + location, + temperature = temp, + unit, + condition = "Sunny" + }); + + case "calculate": + var expression = arguments.GetProperty("expression") + .GetString() ?? ""; + try + { + var result = new System.Data.DataTable() + .Compute(expression, null); + return JsonSerializer.Serialize(new + { + expression, + result = result?.ToString() + }); + } + catch (Exception ex) + { + return JsonSerializer.Serialize(new + { + error = ex.Message + }); + } + + default: + return JsonSerializer.Serialize(new + { + error = $"Unknown function: {functionName}" + }); + } +} +// + +// +// --- Main application --- +var config = new Configuration +{ + AppName = "foundry_local_samples", + LogLevel = Microsoft.AI.Foundry.Local.LogLevel.Information +}; + +using var loggerFactory = LoggerFactory.Create(builder => +{ + builder.SetMinimumLevel( + Microsoft.Extensions.Logging.LogLevel.Information + ); +}); +var logger = loggerFactory.CreateLogger(); + +await FoundryLocalManager.CreateAsync(config, logger); +var mgr = FoundryLocalManager.Instance; + +// Download and register all execution providers. +var currentEp = ""; +await mgr.DownloadAndRegisterEpsAsync((epName, percent) => +{ + if (epName != currentEp) + { + if (currentEp != "") Console.WriteLine(); + currentEp = epName; + } + Console.Write($"\r {epName.PadRight(30)} {percent,6:F1}%"); +}); +if (currentEp != "") Console.WriteLine(); + +var catalog = await mgr.GetCatalogAsync(); +var model = await catalog.GetModelAsync("qwen2.5-0.5b") + ?? throw new Exception("Model not found"); + +await model.DownloadAsync(progress => +{ + Console.Write($"\rDownloading model: {progress:F2}%"); + if (progress >= 100f) Console.WriteLine(); +}); + +await model.LoadAsync(); +Console.WriteLine("Model loaded and ready."); + +var chatClient = await model.GetChatClientAsync(); +chatClient.Settings.ToolChoice = ToolChoice.Auto; + +var messages = new List +{ + new ChatMessage + { + Role = "system", + Content = "You are a helpful assistant with access to tools. " + + "Use them when needed to answer questions accurately." + } +}; +// + +// +Console.WriteLine("\nTool-calling assistant ready! Type 'quit' to exit.\n"); + +while (true) +{ + Console.Write("You: "); + var userInput = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(userInput) || + userInput.Equals("quit", StringComparison.OrdinalIgnoreCase) || + userInput.Equals("exit", StringComparison.OrdinalIgnoreCase)) + { + break; + } + + messages.Add(new ChatMessage + { + Role = "user", + Content = userInput + }); + + var response = await chatClient.CompleteChatAsync( + messages, tools, ct + ); + + var choice = response.Choices[0].Message; + + if (choice.ToolCalls is { Count: > 0 }) + { + messages.Add(choice); + + foreach (var toolCall in choice.ToolCalls) + { + var toolArgs = JsonDocument.Parse( + toolCall.FunctionCall.Arguments + ).RootElement; + Console.WriteLine( + $" Tool call: {toolCall.FunctionCall.Name}({toolArgs})" + ); + + var result = ExecuteTool( + toolCall.FunctionCall.Name, toolArgs + ); + messages.Add(new ChatMessage + { + Role = "tool", + ToolCallId = toolCall.Id, + Content = result + }); + } + + var finalResponse = await chatClient.CompleteChatAsync( + messages, tools, ct + ); + var answer = finalResponse.Choices[0].Message.Content ?? ""; + messages.Add(new ChatMessage + { + Role = "assistant", + Content = answer + }); + Console.WriteLine($"Assistant: {answer}\n"); + } + else + { + var answer = choice.Content ?? ""; + messages.Add(new ChatMessage + { + Role = "assistant", + Content = answer + }); + Console.WriteLine($"Assistant: {answer}\n"); + } +} + +await model.UnloadAsync(); +Console.WriteLine("Model unloaded. Goodbye!"); +// +// diff --git a/samples/csharp/foundry-local/tutorial-tool-calling/README.md b/samples/csharp/foundry-local/tutorial-tool-calling/README.md new file mode 100644 index 000000000..3466fd1c9 --- /dev/null +++ b/samples/csharp/foundry-local/tutorial-tool-calling/README.md @@ -0,0 +1,18 @@ +# Tutorial: Tool Calling (C#) + +Create a tool-calling assistant (tutorial). + +> Part of the [Foundry Local samples for C#](../). + +## Prerequisites + +- [Foundry Local](https://learn.microsoft.com/azure/foundry-local/) installed +- .NET 9 SDK + +## Run + +```bash +dotnet run +``` + +See the [Foundry Local documentation](https://learn.microsoft.com/azure/foundry-local/) for more details. diff --git a/samples/csharp/foundry-local/tutorial-tool-calling/TutorialToolCalling.csproj b/samples/csharp/foundry-local/tutorial-tool-calling/TutorialToolCalling.csproj new file mode 100644 index 000000000..d1e25e90a --- /dev/null +++ b/samples/csharp/foundry-local/tutorial-tool-calling/TutorialToolCalling.csproj @@ -0,0 +1,49 @@ + + + + Exe + enable + enable + + + + + net8.0-windows10.0.18362.0 + ARM64;x64 + None + false + + + + + net8.0 + + + + $(NETCoreSdkRuntimeIdentifier) + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/csharp/foundry-local/tutorial-tool-calling/TutorialToolCalling.sln b/samples/csharp/foundry-local/tutorial-tool-calling/TutorialToolCalling.sln new file mode 100644 index 000000000..41082e5c2 --- /dev/null +++ b/samples/csharp/foundry-local/tutorial-tool-calling/TutorialToolCalling.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TutorialToolCalling", "TutorialToolCalling.csproj", "{155923AB-A0C6-447D-A46A-7C8318D31596}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {155923AB-A0C6-447D-A46A-7C8318D31596}.Debug|Any CPU.ActiveCfg = Debug|ARM64 + {155923AB-A0C6-447D-A46A-7C8318D31596}.Debug|Any CPU.Build.0 = Debug|ARM64 + {155923AB-A0C6-447D-A46A-7C8318D31596}.Debug|x64.ActiveCfg = Debug|x64 + {155923AB-A0C6-447D-A46A-7C8318D31596}.Debug|x64.Build.0 = Debug|x64 + {155923AB-A0C6-447D-A46A-7C8318D31596}.Debug|x86.ActiveCfg = Debug|ARM64 + {155923AB-A0C6-447D-A46A-7C8318D31596}.Debug|x86.Build.0 = Debug|ARM64 + {155923AB-A0C6-447D-A46A-7C8318D31596}.Release|Any CPU.ActiveCfg = Release|ARM64 + {155923AB-A0C6-447D-A46A-7C8318D31596}.Release|Any CPU.Build.0 = Release|ARM64 + {155923AB-A0C6-447D-A46A-7C8318D31596}.Release|x64.ActiveCfg = Release|x64 + {155923AB-A0C6-447D-A46A-7C8318D31596}.Release|x64.Build.0 = Release|x64 + {155923AB-A0C6-447D-A46A-7C8318D31596}.Release|x86.ActiveCfg = Release|ARM64 + {155923AB-A0C6-447D-A46A-7C8318D31596}.Release|x86.Build.0 = Release|ARM64 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/samples/csharp/foundry-local/tutorial-tool-calling/sample.yaml b/samples/csharp/foundry-local/tutorial-tool-calling/sample.yaml new file mode 100644 index 000000000..fbd6bb6f1 --- /dev/null +++ b/samples/csharp/foundry-local/tutorial-tool-calling/sample.yaml @@ -0,0 +1,4 @@ +name: "Tutorial: Tool Calling (C#)" +description: Create a tool-calling assistant (tutorial). + +build: "dotnet restore ./*.csproj --source https://api.nuget.org/v3/index.json && dotnet build ./*.csproj --no-restore --verbosity minimal" diff --git a/samples/csharp/foundry-local/tutorial-voice-to-text/Program.cs b/samples/csharp/foundry-local/tutorial-voice-to-text/Program.cs new file mode 100644 index 000000000..9a1a36c33 --- /dev/null +++ b/samples/csharp/foundry-local/tutorial-voice-to-text/Program.cs @@ -0,0 +1,118 @@ +// +// +using Microsoft.AI.Foundry.Local; +using Betalgo.Ranul.OpenAI.ObjectModels.RequestModels; +using Microsoft.Extensions.Logging; +using System.Text; +// + +// +CancellationToken ct = CancellationToken.None; + +var config = new Configuration +{ + AppName = "foundry_local_samples", + LogLevel = Microsoft.AI.Foundry.Local.LogLevel.Information +}; + +using var loggerFactory = LoggerFactory.Create(builder => +{ + builder.SetMinimumLevel( + Microsoft.Extensions.Logging.LogLevel.Information + ); +}); +var logger = loggerFactory.CreateLogger(); + +// Initialize the singleton instance +await FoundryLocalManager.CreateAsync(config, logger); +var mgr = FoundryLocalManager.Instance; + +// Download and register all execution providers. +var currentEp = ""; +await mgr.DownloadAndRegisterEpsAsync((epName, percent) => +{ + if (epName != currentEp) + { + if (currentEp != "") Console.WriteLine(); + currentEp = epName; + } + Console.Write($"\r {epName.PadRight(30)} {percent,6:F1}%"); +}); +if (currentEp != "") Console.WriteLine(); + +var catalog = await mgr.GetCatalogAsync(); +// + +// +// Load the speech-to-text model +var speechModel = await catalog.GetModelAsync("whisper-tiny") + ?? throw new Exception("Speech model not found"); + +await speechModel.DownloadAsync(progress => +{ + Console.Write($"\rDownloading speech model: {progress:F2}%"); + if (progress >= 100f) Console.WriteLine(); +}); + +await speechModel.LoadAsync(); +Console.WriteLine("Speech model loaded."); + +// Transcribe the audio file +var audioClient = await speechModel.GetAudioClientAsync(); +var transcriptionText = new StringBuilder(); + +Console.WriteLine("\nTranscription:"); +var audioResponse = audioClient + .TranscribeAudioStreamingAsync("meeting-notes.wav", ct); +await foreach (var chunk in audioResponse) +{ + Console.Write(chunk.Text); + transcriptionText.Append(chunk.Text); +} +Console.WriteLine(); + +// Unload the speech model to free memory +await speechModel.UnloadAsync(); +// + +// +// Load the chat model for summarization +var chatModel = await catalog.GetModelAsync("qwen2.5-0.5b") + ?? throw new Exception("Chat model not found"); + +await chatModel.DownloadAsync(progress => +{ + Console.Write($"\rDownloading chat model: {progress:F2}%"); + if (progress >= 100f) Console.WriteLine(); +}); + +await chatModel.LoadAsync(); +Console.WriteLine("Chat model loaded."); + +// Summarize the transcription into organized notes +var chatClient = await chatModel.GetChatClientAsync(); +var messages = new List +{ + new ChatMessage + { + Role = "system", + Content = "You are a note-taking assistant. Summarize " + + "the following transcription into organized, " + + "concise notes with bullet points." + }, + new ChatMessage + { + Role = "user", + Content = transcriptionText.ToString() + } +}; + +var chatResponse = await chatClient.CompleteChatAsync(messages, ct); +var summary = chatResponse.Choices[0].Message.Content; +Console.WriteLine($"\nSummary:\n{summary}"); + +// Clean up +await chatModel.UnloadAsync(); +Console.WriteLine("\nDone. Models unloaded."); +// +// diff --git a/samples/csharp/foundry-local/tutorial-voice-to-text/README.md b/samples/csharp/foundry-local/tutorial-voice-to-text/README.md new file mode 100644 index 000000000..8d6ae8b11 --- /dev/null +++ b/samples/csharp/foundry-local/tutorial-voice-to-text/README.md @@ -0,0 +1,18 @@ +# Tutorial: Voice to Text (C#) + +Transcribe and summarize audio (tutorial). + +> Part of the [Foundry Local samples for C#](../). + +## Prerequisites + +- [Foundry Local](https://learn.microsoft.com/azure/foundry-local/) installed +- .NET 9 SDK + +## Run + +```bash +dotnet run +``` + +See the [Foundry Local documentation](https://learn.microsoft.com/azure/foundry-local/) for more details. diff --git a/samples/csharp/foundry-local/tutorial-voice-to-text/TutorialVoiceToText.csproj b/samples/csharp/foundry-local/tutorial-voice-to-text/TutorialVoiceToText.csproj new file mode 100644 index 000000000..d1e25e90a --- /dev/null +++ b/samples/csharp/foundry-local/tutorial-voice-to-text/TutorialVoiceToText.csproj @@ -0,0 +1,49 @@ + + + + Exe + enable + enable + + + + + net8.0-windows10.0.18362.0 + ARM64;x64 + None + false + + + + + net8.0 + + + + $(NETCoreSdkRuntimeIdentifier) + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/csharp/foundry-local/tutorial-voice-to-text/TutorialVoiceToText.sln b/samples/csharp/foundry-local/tutorial-voice-to-text/TutorialVoiceToText.sln new file mode 100644 index 000000000..0c0c7c499 --- /dev/null +++ b/samples/csharp/foundry-local/tutorial-voice-to-text/TutorialVoiceToText.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TutorialVoiceToText", "TutorialVoiceToText.csproj", "{C12663C3-AB3F-4652-BC43-A92E43602ACC}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C12663C3-AB3F-4652-BC43-A92E43602ACC}.Debug|Any CPU.ActiveCfg = Debug|ARM64 + {C12663C3-AB3F-4652-BC43-A92E43602ACC}.Debug|Any CPU.Build.0 = Debug|ARM64 + {C12663C3-AB3F-4652-BC43-A92E43602ACC}.Debug|x64.ActiveCfg = Debug|x64 + {C12663C3-AB3F-4652-BC43-A92E43602ACC}.Debug|x64.Build.0 = Debug|x64 + {C12663C3-AB3F-4652-BC43-A92E43602ACC}.Debug|x86.ActiveCfg = Debug|ARM64 + {C12663C3-AB3F-4652-BC43-A92E43602ACC}.Debug|x86.Build.0 = Debug|ARM64 + {C12663C3-AB3F-4652-BC43-A92E43602ACC}.Release|Any CPU.ActiveCfg = Release|ARM64 + {C12663C3-AB3F-4652-BC43-A92E43602ACC}.Release|Any CPU.Build.0 = Release|ARM64 + {C12663C3-AB3F-4652-BC43-A92E43602ACC}.Release|x64.ActiveCfg = Release|x64 + {C12663C3-AB3F-4652-BC43-A92E43602ACC}.Release|x64.Build.0 = Release|x64 + {C12663C3-AB3F-4652-BC43-A92E43602ACC}.Release|x86.ActiveCfg = Release|ARM64 + {C12663C3-AB3F-4652-BC43-A92E43602ACC}.Release|x86.Build.0 = Release|ARM64 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/samples/csharp/foundry-local/tutorial-voice-to-text/sample.yaml b/samples/csharp/foundry-local/tutorial-voice-to-text/sample.yaml new file mode 100644 index 000000000..452c425e2 --- /dev/null +++ b/samples/csharp/foundry-local/tutorial-voice-to-text/sample.yaml @@ -0,0 +1,4 @@ +name: "Tutorial: Voice to Text (C#)" +description: Transcribe and summarize audio (tutorial). + +build: "dotnet restore ./*.csproj --source https://api.nuget.org/v3/index.json && dotnet build ./*.csproj --no-restore --verbosity minimal"